Files
OpenAppLock/OpenAppLockTests/SchedulingTests.swift
Brendan Chen 1652c2f410 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)
2026-06-12 21:06:08 -04:00

280 lines
11 KiB
Swift

//
// 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)
}
}