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