feat: enforce time and open limits in the background via Screen Time extensions
- Shared/ layer compiled into the app and three new extension targets: rule snapshots in the app group, the usage ledger, monitoring-plan naming, and LimitEnforcement (shared, unit-tested event reactions) - RuleScheduler mirrors rules to the app group and reconciles DeviceActivity monitoring: one daily 00:00-23:59 activity per limit rule, with a cumulative usage-threshold event per budget minute for time limits; activities restart only when their configuration changes (a restart resets threshold accounting) - OpenAppLockMonitor (DeviceActivityMonitor): midnight budget resets, records usage minutes, shields at the budget, re-shields when a granted open session ends - OpenAppLockShieldConfig: open-limit shields show 'Opened X of N times today' with an 'Open (Y left)' secondary button - OpenAppLockShieldAction: an Open press spends one open, lifts the rule's shield, and starts the ~15-minute one-shot session - extensions are classic NSExtension app extensions (the ExtensionKit product type expects an @main entry and made the app fail to install); shield-store tracking moved to app-group defaults so the app and extensions see one consistent set - maps iOS 26's new .approvedWithDataAccess authorization status to approved (it previously fell through @unknown default to notDetermined) - shares the OpenAppLock scheme (Xcode dropped the autocreated one when targets were added)
This commit is contained in:
279
OpenAppLockTests/SchedulingTests.swift
Normal file
279
OpenAppLockTests/SchedulingTests.swift
Normal file
@@ -0,0 +1,279 @@
|
||||
//
|
||||
// SchedulingTests.swift
|
||||
// OpenAppLockTests
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import Testing
|
||||
|
||||
@testable import OpenAppLock
|
||||
|
||||
private func freshDefaults() -> UserDefaults {
|
||||
let name = "scheduling-tests-\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: name)!
|
||||
defaults.removePersistentDomain(forName: name)
|
||||
return defaults
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Suite("Rule snapshots")
|
||||
struct RuleSnapshotTests {
|
||||
@Test("Snapshots round-trip through the shared store")
|
||||
func storeRoundTrip() throws {
|
||||
let store = RuleSnapshotStore(defaults: freshDefaults())
|
||||
let snapshot = RuleSnapshot(
|
||||
id: UUID(), name: "Time Keeper", kindRaw: "timeLimit", isEnabled: true,
|
||||
hardMode: false, blockAdultContent: false, selectionModeRaw: "block",
|
||||
selectionData: Data([1]), dayNumbers: [2, 3], dailyLimitMinutes: 45, maxOpens: 5,
|
||||
pausedUntil: nil
|
||||
)
|
||||
store.save([snapshot])
|
||||
#expect(store.load() == [snapshot])
|
||||
#expect(store.snapshot(for: snapshot.id) == snapshot)
|
||||
#expect(store.snapshot(for: UUID()) == nil)
|
||||
}
|
||||
|
||||
@Test("Snapshots mirror rules and their app lists")
|
||||
func mirrorsRule() throws {
|
||||
let context = try makeInMemoryContext()
|
||||
let list = AppList(name: "Distractions", selectionData: Data([9]), selectionCount: 1)
|
||||
let rule = BlockingRule(
|
||||
name: "Gate Keeper", kind: .openLimit, days: Weekday.weekends, maxOpens: 3)
|
||||
context.insert(list)
|
||||
context.insert(rule)
|
||||
rule.appList = list
|
||||
|
||||
let snapshot = RuleSnapshot(rule: rule)
|
||||
#expect(snapshot.id == rule.id)
|
||||
#expect(snapshot.kind == .openLimit)
|
||||
#expect(snapshot.selectionData == Data([9]))
|
||||
#expect(snapshot.days == Weekday.weekends)
|
||||
#expect(snapshot.maxOpens == 3)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Suite("Monitoring plan")
|
||||
struct MonitoringPlanTests {
|
||||
@Test("Activity names round-trip rule IDs")
|
||||
func nameRoundTrip() {
|
||||
let id = UUID()
|
||||
#expect(
|
||||
MonitoringPlan.ruleID(
|
||||
fromDailyActivityName: MonitoringPlan.dailyActivityName(for: id)) == id)
|
||||
#expect(
|
||||
MonitoringPlan.ruleID(
|
||||
fromSessionActivityName: MonitoringPlan.sessionActivityName(for: id)) == id)
|
||||
#expect(MonitoringPlan.ruleID(fromDailyActivityName: "garbage") == nil)
|
||||
#expect(
|
||||
MonitoringPlan.ruleID(
|
||||
fromSessionActivityName: MonitoringPlan.dailyActivityName(for: id)) == nil)
|
||||
}
|
||||
|
||||
@Test("Minute checkpoints cover every minute up to the budget")
|
||||
func minuteEvents() {
|
||||
let events = MonitoringPlan.minuteEvents(forLimit: 45)
|
||||
#expect(events.count == 45)
|
||||
#expect(events[MonitoringPlan.minuteEventName(for: 1)] == 1)
|
||||
#expect(events[MonitoringPlan.minuteEventName(for: 45)] == 45)
|
||||
#expect(MonitoringPlan.minutes(fromEventName: MonitoringPlan.minuteEventName(for: 17)) == 17)
|
||||
#expect(MonitoringPlan.minutes(fromEventName: "nope") == nil)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Suite("Rule scheduler → DeviceActivity")
|
||||
struct RuleSchedulerTests {
|
||||
private func makeScheduler() -> (RuleScheduler, MockActivityMonitor, RuleSnapshotStore) {
|
||||
let monitor = MockActivityMonitor()
|
||||
let store = RuleSnapshotStore(defaults: freshDefaults())
|
||||
return (RuleScheduler(monitor: monitor, snapshots: store), monitor, store)
|
||||
}
|
||||
|
||||
private func limitRule(kind: RuleKind, name: String) throws -> BlockingRule {
|
||||
let context = try makeInMemoryContext()
|
||||
let list = AppList(name: "Apps", selectionData: Data([1]), selectionCount: 1)
|
||||
let rule = BlockingRule(name: name, kind: kind, days: Weekday.everyDay)
|
||||
context.insert(list)
|
||||
context.insert(rule)
|
||||
rule.appList = list
|
||||
return rule
|
||||
}
|
||||
|
||||
@Test("Enabled limit rules with apps start daily monitoring")
|
||||
func startsMonitoring() throws {
|
||||
let (scheduler, monitor, store) = makeScheduler()
|
||||
let rule = try limitRule(kind: .timeLimit, name: "Time Keeper")
|
||||
|
||||
scheduler.sync(rules: [rule])
|
||||
|
||||
let name = MonitoringPlan.dailyActivityName(for: rule.id)
|
||||
#expect(monitor.monitoredNames == [name])
|
||||
#expect(monitor.startedEvents[name]?.count == rule.dailyLimitMinutes)
|
||||
#expect(store.snapshot(for: rule.id) != nil)
|
||||
}
|
||||
|
||||
@Test("Open-limit rules monitor the day without usage checkpoints")
|
||||
func openLimitHasNoCheckpoints() throws {
|
||||
let (scheduler, monitor, _) = makeScheduler()
|
||||
let rule = try limitRule(kind: .openLimit, name: "Gate Keeper")
|
||||
|
||||
scheduler.sync(rules: [rule])
|
||||
|
||||
#expect(monitor.startedEvents[MonitoringPlan.dailyActivityName(for: rule.id)]?.isEmpty == true)
|
||||
}
|
||||
|
||||
@Test("Schedule rules and app-less limit rules are not monitored")
|
||||
func skipsUnmonitorable() throws {
|
||||
let (scheduler, monitor, _) = makeScheduler()
|
||||
let context = try makeInMemoryContext()
|
||||
let scheduleRule = BlockingRule(name: "Work Time")
|
||||
let appless = BlockingRule(name: "Empty", kind: .timeLimit)
|
||||
context.insert(scheduleRule)
|
||||
context.insert(appless)
|
||||
|
||||
scheduler.sync(rules: [scheduleRule, appless])
|
||||
|
||||
#expect(monitor.monitoredNames.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Monitoring stops when a rule is disabled or removed")
|
||||
func stopsStaleMonitoring() throws {
|
||||
let (scheduler, monitor, _) = makeScheduler()
|
||||
let rule = try limitRule(kind: .timeLimit, name: "Time Keeper")
|
||||
scheduler.sync(rules: [rule])
|
||||
|
||||
rule.isEnabled = false
|
||||
scheduler.sync(rules: [rule])
|
||||
#expect(monitor.monitoredNames.isEmpty)
|
||||
|
||||
rule.isEnabled = true
|
||||
scheduler.sync(rules: [rule])
|
||||
scheduler.sync(rules: [])
|
||||
#expect(monitor.monitoredNames.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Unchanged rules are not restarted (checkpoints would reset)")
|
||||
func avoidsRestartChurn() throws {
|
||||
let (scheduler, monitor, _) = makeScheduler()
|
||||
let rule = try limitRule(kind: .timeLimit, name: "Time Keeper")
|
||||
|
||||
scheduler.sync(rules: [rule])
|
||||
scheduler.sync(rules: [rule])
|
||||
#expect(monitor.startCallCount == 1)
|
||||
|
||||
rule.dailyLimitMinutes = 60
|
||||
scheduler.sync(rules: [rule])
|
||||
#expect(monitor.startCallCount == 2)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Suite("Limit enforcement reactions")
|
||||
struct LimitEnforcementTests {
|
||||
let monday = date(2025, 1, 6, 10, 0)
|
||||
|
||||
private func makeEnforcement() -> (LimitEnforcement, MockShieldController, UsageLedger, RuleSnapshotStore) {
|
||||
let shields = MockShieldController()
|
||||
let ledger = UsageLedger(defaults: freshDefaults())
|
||||
let store = RuleSnapshotStore(defaults: freshDefaults())
|
||||
return (LimitEnforcement(snapshots: store, ledger: ledger, shields: shields), shields, ledger, store)
|
||||
}
|
||||
|
||||
private func snapshot(
|
||||
kind: RuleKind, limit: Int = 45, maxOpens: Int = 5, pausedUntil: Date? = nil
|
||||
) -> RuleSnapshot {
|
||||
RuleSnapshot(
|
||||
id: UUID(), name: "Rule", kindRaw: kind.rawValue, isEnabled: true,
|
||||
hardMode: false, blockAdultContent: false, selectionModeRaw: "block",
|
||||
selectionData: Data([1]), dayNumbers: Weekday.everyDay.map(\.rawValue),
|
||||
dailyLimitMinutes: limit, maxOpens: maxOpens, pausedUntil: pausedUntil
|
||||
)
|
||||
}
|
||||
|
||||
@Test("Day start shields open-limit rules so opens can be counted")
|
||||
func dayStartShieldsOpenLimit() {
|
||||
let (enforcement, shields, _, store) = makeEnforcement()
|
||||
let snap = snapshot(kind: .openLimit)
|
||||
store.save([snap])
|
||||
|
||||
enforcement.handleDayStart(ruleID: snap.id, now: monday, calendar: utc)
|
||||
|
||||
#expect(shields.shieldedRuleIDs == [snap.id])
|
||||
}
|
||||
|
||||
@Test("Day start clears time-limit shields for the fresh budget")
|
||||
func dayStartClearsTimeLimit() {
|
||||
let (enforcement, shields, _, store) = makeEnforcement()
|
||||
let snap = snapshot(kind: .timeLimit)
|
||||
store.save([snap])
|
||||
shields.applyShield(
|
||||
ruleID: snap.id, selectionData: nil, mode: .block, blockAdultContent: false)
|
||||
|
||||
enforcement.handleDayStart(ruleID: snap.id, now: monday, calendar: utc)
|
||||
|
||||
#expect(shields.shieldedRuleIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Usage checkpoints record minutes and shield at the limit")
|
||||
func usageCheckpointsShieldAtLimit() {
|
||||
let (enforcement, shields, ledger, store) = makeEnforcement()
|
||||
let snap = snapshot(kind: .timeLimit, limit: 45)
|
||||
store.save([snap])
|
||||
|
||||
enforcement.handleUsageMinutes(20, ruleID: snap.id, now: monday, calendar: utc)
|
||||
#expect(ledger.usage(for: snap.id, onDayContaining: monday, calendar: utc).minutesUsed == 20)
|
||||
#expect(shields.shieldedRuleIDs.isEmpty)
|
||||
|
||||
enforcement.handleUsageMinutes(45, ruleID: snap.id, now: monday, calendar: utc)
|
||||
#expect(shields.shieldedRuleIDs == [snap.id])
|
||||
}
|
||||
|
||||
@Test("An Open press spends one open and lifts the shield")
|
||||
func openRequestSpendsAndLifts() {
|
||||
let (enforcement, shields, ledger, store) = makeEnforcement()
|
||||
let snap = snapshot(kind: .openLimit, maxOpens: 2)
|
||||
store.save([snap])
|
||||
enforcement.handleDayStart(ruleID: snap.id, now: monday, calendar: utc)
|
||||
|
||||
#expect(enforcement.handleOpenRequest(ruleID: snap.id, now: monday, calendar: utc))
|
||||
#expect(ledger.usage(for: snap.id, onDayContaining: monday, calendar: utc).opensUsed == 1)
|
||||
#expect(shields.shieldedRuleIDs.isEmpty)
|
||||
}
|
||||
|
||||
@Test("Open presses are refused once opens are exhausted")
|
||||
func openRequestRefusedWhenExhausted() {
|
||||
let (enforcement, shields, ledger, store) = makeEnforcement()
|
||||
let snap = snapshot(kind: .openLimit, maxOpens: 1)
|
||||
store.save([snap])
|
||||
ledger.recordOpen(for: snap.id, onDayContaining: monday, calendar: utc)
|
||||
enforcement.handleDayStart(ruleID: snap.id, now: monday, calendar: utc)
|
||||
|
||||
#expect(!enforcement.handleOpenRequest(ruleID: snap.id, now: monday, calendar: utc))
|
||||
#expect(shields.shieldedRuleIDs == [snap.id])
|
||||
}
|
||||
|
||||
@Test("Session end re-shields the rule for the next open")
|
||||
func sessionEndReshields() {
|
||||
let (enforcement, shields, _, store) = makeEnforcement()
|
||||
let snap = snapshot(kind: .openLimit)
|
||||
store.save([snap])
|
||||
|
||||
enforcement.handleOpenSessionEnded(ruleID: snap.id, now: monday, calendar: utc)
|
||||
|
||||
#expect(shields.shieldedRuleIDs == [snap.id])
|
||||
}
|
||||
|
||||
@Test("A paused (unblocked) rule is left alone until midnight")
|
||||
func pausedRuleLeftAlone() {
|
||||
let (enforcement, shields, _, store) = makeEnforcement()
|
||||
let snap = snapshot(kind: .openLimit, pausedUntil: date(2025, 1, 7, 0, 0))
|
||||
store.save([snap])
|
||||
|
||||
enforcement.handleDayStart(ruleID: snap.id, now: monday, calendar: utc)
|
||||
enforcement.handleOpenSessionEnded(ruleID: snap.id, now: monday, calendar: utc)
|
||||
|
||||
#expect(shields.shieldedRuleIDs.isEmpty)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user