Replace the custom themed presentation with the bare native iOS design language across a three-tab TabView (Home / Rules / Settings): - MainTabView hosts the enforcement lifecycle (refresh loop, rule-change and scene-active reconcile) so it runs regardless of the selected tab - HomeView: "Currently Blocking" + "Usage" sections (replaces AppsHomeView) - RulesListView: rules grouped into Schedule / Time Limit / Open Limit - SettingsView: Uninstall Protection toggle + Manage App Lists - AppListLibraryView / ManageAppListsView: shared App List management - AppSettings: app-group-backed settings store Uninstall Protection: while enabled and any Hard Mode rule is actively blocking, deny device app-removal via a dedicated ManagedSettingsStore (RulePolicy.shouldDenyAppRemoval -> RuleEnforcer -> ShieldController). Slim AppListPickerSheet to the picker role; drop AppsHomeView and the custom Theme/HoldToCommitButton/RuleCardView chrome. Update spec section 6 and the unit/UI test suites accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
340 lines
14 KiB
Swift
340 lines
14 KiB
Swift
//
|
||
// RuleEnforcerTests.swift
|
||
// OpenAppLockTests
|
||
//
|
||
|
||
import Foundation
|
||
import Testing
|
||
|
||
@testable import OpenAppLock
|
||
|
||
@MainActor
|
||
@Suite("Rule enforcement → shields")
|
||
struct RuleEnforcerTests {
|
||
let mondayDuringWork = date(2025, 1, 6, 10, 0)
|
||
let mondayEvening = date(2025, 1, 6, 19, 0)
|
||
|
||
@Test("Active schedule rules are shielded; inactive ones are not")
|
||
func shieldsActiveRules() {
|
||
let shields = MockShieldController()
|
||
let enforcer = RuleEnforcer(shields: shields)
|
||
let active = BlockingRule(name: "Work Time")
|
||
let weekendOnly = BlockingRule(name: "Weekend Zen", days: Weekday.weekends)
|
||
|
||
enforcer.refresh(rules: [active, weekendOnly], at: mondayDuringWork, calendar: utc)
|
||
|
||
#expect(shields.shieldedRuleIDs == [active.id])
|
||
#expect(enforcer.blockingRuleIDs == [active.id])
|
||
}
|
||
|
||
@Test("Disabled rules are never shielded")
|
||
func skipsDisabledRules() {
|
||
let shields = MockShieldController()
|
||
let enforcer = RuleEnforcer(shields: shields)
|
||
let rule = BlockingRule(name: "Work Time", isEnabled: false)
|
||
|
||
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
|
||
|
||
#expect(shields.shieldedRuleIDs.isEmpty)
|
||
}
|
||
|
||
@Test("Paused rules are not shielded")
|
||
func skipsPausedRules() {
|
||
let shields = MockShieldController()
|
||
let enforcer = RuleEnforcer(shields: shields)
|
||
let rule = BlockingRule(name: "Work Time")
|
||
RulePolicy.unblock(rule, at: mondayDuringWork, calendar: utc)
|
||
|
||
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
|
||
|
||
#expect(shields.shieldedRuleIDs.isEmpty)
|
||
}
|
||
|
||
@Test("Time-limit rules are not schedule-shielded")
|
||
func skipsTimeLimitRules() {
|
||
let shields = MockShieldController()
|
||
let enforcer = RuleEnforcer(shields: shields)
|
||
let rule = BlockingRule(name: "Time Keeper", configuration: .timeLimit(TimeLimitConfig()))
|
||
|
||
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
|
||
|
||
#expect(shields.shieldedRuleIDs.isEmpty)
|
||
}
|
||
|
||
@Test("Shields are cleared when a window ends")
|
||
func clearsShieldAfterWindow() {
|
||
let shields = MockShieldController()
|
||
let enforcer = RuleEnforcer(shields: shields)
|
||
let rule = BlockingRule(name: "Work Time")
|
||
|
||
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
|
||
#expect(shields.shieldedRuleIDs == [rule.id])
|
||
|
||
enforcer.refresh(rules: [rule], at: mondayEvening, calendar: utc)
|
||
#expect(shields.shieldedRuleIDs.isEmpty)
|
||
#expect(enforcer.blockingRuleIDs.isEmpty)
|
||
}
|
||
|
||
@Test("Shields are cleared when a rule is deleted")
|
||
func clearsShieldAfterDeletion() {
|
||
let shields = MockShieldController()
|
||
let enforcer = RuleEnforcer(shields: shields)
|
||
let rule = BlockingRule(name: "Work Time")
|
||
|
||
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
|
||
enforcer.refresh(rules: [], at: mondayDuringWork, calendar: utc)
|
||
|
||
#expect(shields.shieldedRuleIDs.isEmpty)
|
||
}
|
||
|
||
@Test("Expired pauses are cleaned up during refresh")
|
||
func clearsExpiredPause() {
|
||
let shields = MockShieldController()
|
||
let enforcer = RuleEnforcer(shields: shields)
|
||
let rule = BlockingRule(name: "Work Time")
|
||
rule.pausedUntil = date(2025, 1, 6, 9, 30)
|
||
|
||
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
|
||
|
||
#expect(rule.pausedUntil == nil)
|
||
#expect(shields.shieldedRuleIDs == [rule.id])
|
||
}
|
||
|
||
@Test("The selection mode is forwarded to the shield layer")
|
||
func forwardsSelectionMode() {
|
||
let shields = MockShieldController()
|
||
let enforcer = RuleEnforcer(shields: shields)
|
||
let rule = BlockingRule(
|
||
name: "Focus", configuration: .schedule(ScheduleConfig(selectionMode: .allowOnly)))
|
||
|
||
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
|
||
|
||
#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()
|
||
let enforcer = RuleEnforcer(shields: shields)
|
||
let filtered = BlockingRule(
|
||
name: "Clean Mode", configuration: .schedule(ScheduleConfig(blockAdultContent: true)))
|
||
let unfiltered = BlockingRule(name: "Plain")
|
||
|
||
enforcer.refresh(rules: [filtered, unfiltered], at: mondayDuringWork, calendar: utc)
|
||
|
||
#expect(shields.appliedAdultContentFlags[filtered.id] == true)
|
||
#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])
|
||
}
|
||
}
|
||
|
||
/// Uninstall Protection: `refresh` denies device app removal only while the
|
||
/// user opted in *and* a Hard Mode rule is actively blocking.
|
||
@MainActor
|
||
@Suite("Uninstall protection enforcement")
|
||
struct UninstallProtectionEnforcementTests {
|
||
let mondayDuringWork = date(2025, 1, 6, 10, 0) // inside the default 09:00–17:00
|
||
let mondayEvening = date(2025, 1, 6, 19, 0) // outside it
|
||
|
||
private func hardRule() -> BlockingRule {
|
||
BlockingRule(name: "Locked In", hardMode: true)
|
||
}
|
||
|
||
@Test("Disabled setting never denies app removal")
|
||
func disabledSettingNeverDenies() {
|
||
let shields = MockShieldController()
|
||
let enforcer = RuleEnforcer(
|
||
shields: shields, settings: MockAppSettings(uninstallProtectionEnabled: false))
|
||
|
||
enforcer.refresh(rules: [hardRule()], at: mondayDuringWork, calendar: utc)
|
||
|
||
#expect(!shields.appRemovalDenied)
|
||
}
|
||
|
||
@Test("Enabled setting denies removal while a hard rule is blocking")
|
||
func deniesDuringHardBlock() {
|
||
let shields = MockShieldController()
|
||
let enforcer = RuleEnforcer(
|
||
shields: shields, settings: MockAppSettings(uninstallProtectionEnabled: true))
|
||
|
||
enforcer.refresh(rules: [hardRule()], at: mondayDuringWork, calendar: utc)
|
||
|
||
#expect(shields.appRemovalDenied)
|
||
}
|
||
|
||
@Test("A soft rule does not deny removal even with the setting on")
|
||
func softRuleDoesNotDeny() {
|
||
let shields = MockShieldController()
|
||
let enforcer = RuleEnforcer(
|
||
shields: shields, settings: MockAppSettings(uninstallProtectionEnabled: true))
|
||
|
||
enforcer.refresh(rules: [BlockingRule(name: "Work Time")], at: mondayDuringWork, calendar: utc)
|
||
|
||
#expect(!shields.appRemovalDenied)
|
||
}
|
||
|
||
@Test("Denial lifts once the hard window ends")
|
||
func liftsWhenWindowEnds() {
|
||
let shields = MockShieldController()
|
||
let enforcer = RuleEnforcer(
|
||
shields: shields, settings: MockAppSettings(uninstallProtectionEnabled: true))
|
||
let rule = hardRule()
|
||
|
||
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
|
||
#expect(shields.appRemovalDenied)
|
||
|
||
enforcer.refresh(rules: [rule], at: mondayEvening, calendar: utc)
|
||
#expect(!shields.appRemovalDenied)
|
||
}
|
||
|
||
@Test("clearShields(except:) does not disturb the app-removal denial")
|
||
func clearShieldsPreservesDenial() {
|
||
let shields = MockShieldController()
|
||
shields.setAppRemovalDenied(true)
|
||
shields.clearShields(except: [])
|
||
#expect(shields.appRemovalDenied)
|
||
}
|
||
}
|