fix: enforce open-limit rules proactively in the foreground
RuleEnforcer.refresh only shielded a limit rule once its budget was spent, so it tore down the background's proactive open-limit "turnstile" shield (the shield is how opens are counted) whenever the app was foregrounded, and a freshly created open-limit rule did nothing until the next midnight reset. refresh now applies the same proactive gate as LimitEnforcement.handleDayStart: an enabled, scheduled-today, un-paused open-limit rule is shielded even before its budget is spent, so it takes effect immediately. A new app-group OpenSessionStore records a granted "Open" session's expiry so neither enforcement path re-shields (and cuts short) a sanctioned ~15-minute session. Overlapping rules already enforce strictly: each rule shields its own ManagedSettingsStore and Screen Time unions them, so an app is blocked if any covering rule blocks it. Document that model in the spec (§4.8) and lock it in with tests, including a time limit blocking during an open-limit's granted session and a daily opens reset. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -112,6 +112,47 @@ struct RuleEnforcerTests {
|
||||
#expect(shields.appliedModes[rule.id] == .allowOnly)
|
||||
}
|
||||
|
||||
@Test("Open-limit rules are proactively shielded while opens remain")
|
||||
func proactivelyShieldsOpenLimit() {
|
||||
let shields = MockShieldController()
|
||||
let ledger = MockUsageLedger()
|
||||
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
|
||||
let rule = BlockingRule(
|
||||
name: "Gate Keeper",
|
||||
configuration: .openLimit(OpenLimitConfig(maxOpens: 5)),
|
||||
days: Weekday.everyDay)
|
||||
// 2 of 5 opens spent: budget not reached, so the rule is not "active",
|
||||
// but its apps must still be gated so the next open can be counted.
|
||||
ledger.usageByRule[rule.id] = RuleUsage(opensUsed: 2)
|
||||
|
||||
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
|
||||
|
||||
#expect(shields.shieldedRuleIDs == [rule.id])
|
||||
#expect(shields.appliedModes[rule.id] == .block)
|
||||
// A proactive gate (opens remaining) is not "Blocked Apps"-blocking.
|
||||
#expect(enforcer.blockingRuleIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test("A granted open session is left un-shielded, not re-locked")
|
||||
func respectsGrantedOpenSession() {
|
||||
let shields = MockShieldController()
|
||||
let ledger = MockUsageLedger()
|
||||
let sessions = MockOpenSessionStore()
|
||||
let enforcer = RuleEnforcer(shields: shields, usage: ledger, openSessions: sessions)
|
||||
let rule = BlockingRule(
|
||||
name: "Gate Keeper",
|
||||
configuration: .openLimit(OpenLimitConfig(maxOpens: 5)),
|
||||
days: Weekday.everyDay)
|
||||
ledger.usageByRule[rule.id] = RuleUsage(opensUsed: 2)
|
||||
// The user spent an open and is mid-session; re-shielding would cut the
|
||||
// sanctioned ~15-minute session short.
|
||||
sessions.activeRuleIDs = [rule.id]
|
||||
|
||||
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
|
||||
|
||||
#expect(shields.shieldedRuleIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test("The adult-content flag is forwarded to the shield layer")
|
||||
func forwardsAdultContentFlag() {
|
||||
let shields = MockShieldController()
|
||||
@@ -126,3 +167,105 @@ struct RuleEnforcerTests {
|
||||
#expect(shields.appliedAdultContentFlags[unfiltered.id] == false)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates the "strictest enforcement wins" model for rules that target the
|
||||
/// same apps (spec §4.8). Each rule shields its own store and Screen Time
|
||||
/// unions them, so the unit-level invariant is: every rule that should block
|
||||
/// applies its own shield, and no rule's shield is suppressed by another.
|
||||
@MainActor
|
||||
@Suite("Overlapping rules → strictest enforcement")
|
||||
struct OverlappingRuleEnforcementTests {
|
||||
let mondayDuringWork = date(2025, 1, 6, 10, 0)
|
||||
|
||||
private func openLimitRule(maxOpens: Int = 5) -> BlockingRule {
|
||||
BlockingRule(
|
||||
name: "Gate Keeper",
|
||||
configuration: .openLimit(OpenLimitConfig(maxOpens: maxOpens)),
|
||||
days: Weekday.everyDay)
|
||||
}
|
||||
|
||||
private func timeLimitRule(limit: Int = 45) -> BlockingRule {
|
||||
BlockingRule(
|
||||
name: "Time Keeper",
|
||||
configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: limit)),
|
||||
days: Weekday.everyDay)
|
||||
}
|
||||
|
||||
@Test("Every rule that should block applies its own shield")
|
||||
func eachRuleShieldsIndependently() {
|
||||
let shields = MockShieldController()
|
||||
let ledger = MockUsageLedger()
|
||||
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
|
||||
let schedule = BlockingRule(name: "Work Time") // 09:00–17:00, active now
|
||||
let timeLimit = timeLimitRule()
|
||||
ledger.usageByRule[timeLimit.id] = RuleUsage(minutesUsed: 45) // spent → blocking
|
||||
|
||||
enforcer.refresh(rules: [schedule, timeLimit], at: mondayDuringWork, calendar: utc)
|
||||
|
||||
// Neither cancels the other: both carry their own shield.
|
||||
#expect(shields.shieldedRuleIDs == [schedule.id, timeLimit.id])
|
||||
}
|
||||
|
||||
@Test("The first limit to be spent blocks, whatever the other's budget")
|
||||
func firstSpentLimitBlocks() {
|
||||
let shields = MockShieldController()
|
||||
let ledger = MockUsageLedger()
|
||||
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
|
||||
let openLimit = openLimitRule(maxOpens: 5)
|
||||
let timeLimit = timeLimitRule(limit: 45)
|
||||
ledger.usageByRule[openLimit.id] = RuleUsage(opensUsed: 1) // opens remain
|
||||
ledger.usageByRule[timeLimit.id] = RuleUsage(minutesUsed: 45) // budget spent
|
||||
|
||||
enforcer.refresh(rules: [openLimit, timeLimit], at: mondayDuringWork, calendar: utc)
|
||||
|
||||
// Time-limit blocks (spent) while the open-limit still gates its turnstile.
|
||||
#expect(shields.shieldedRuleIDs == [openLimit.id, timeLimit.id])
|
||||
// Only the spent budget counts as "Blocked Apps"; the gate shows in Usage.
|
||||
#expect(enforcer.blockingRuleIDs == [timeLimit.id])
|
||||
}
|
||||
|
||||
@Test("A time limit blocks even during an open-limit's granted session")
|
||||
func timeLimitBlocksDuringGrantedOpen() {
|
||||
let shields = MockShieldController()
|
||||
let ledger = MockUsageLedger()
|
||||
let sessions = MockOpenSessionStore()
|
||||
let enforcer = RuleEnforcer(shields: shields, usage: ledger, openSessions: sessions)
|
||||
let openLimit = openLimitRule(maxOpens: 5)
|
||||
let timeLimit = timeLimitRule(limit: 45)
|
||||
sessions.activeRuleIDs = [openLimit.id] // user is mid-open on the shared app
|
||||
ledger.usageByRule[openLimit.id] = RuleUsage(opensUsed: 1)
|
||||
// The metered minutes during the open push the time limit over budget.
|
||||
ledger.usageByRule[timeLimit.id] = RuleUsage(minutesUsed: 45)
|
||||
|
||||
enforcer.refresh(rules: [openLimit, timeLimit], at: mondayDuringWork, calendar: utc)
|
||||
|
||||
// The open-limit stays lifted (its session is sanctioned), but the time
|
||||
// limit shields the app anyway — strictest wins.
|
||||
#expect(shields.shieldedRuleIDs == [timeLimit.id])
|
||||
}
|
||||
|
||||
@Test("Spent opens reset the next day: re-gated, not blocked")
|
||||
func opensResetNextDay() {
|
||||
let suite = "overlap-tests-\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suite)!
|
||||
defaults.removePersistentDomain(forName: suite)
|
||||
let ledger = UsageLedger(defaults: defaults)
|
||||
let shields = MockShieldController()
|
||||
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
|
||||
let rule = openLimitRule(maxOpens: 5)
|
||||
let yesterday = date(2025, 1, 5, 10, 0)
|
||||
let today = date(2025, 1, 6, 10, 0)
|
||||
for _ in 0..<5 {
|
||||
ledger.recordOpen(for: rule.id, onDayContaining: yesterday, calendar: utc)
|
||||
}
|
||||
|
||||
// Yesterday: budget exhausted → blocking.
|
||||
enforcer.refresh(rules: [rule], at: yesterday, calendar: utc)
|
||||
#expect(enforcer.blockingRuleIDs == [rule.id])
|
||||
|
||||
// Today: fresh budget → not blocking, but the turnstile is back up.
|
||||
enforcer.refresh(rules: [rule], at: today, calendar: utc)
|
||||
#expect(enforcer.blockingRuleIDs.isEmpty)
|
||||
#expect(shields.shieldedRuleIDs == [rule.id])
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user