Files
OpenAppLock/OpenAppLockTests/RuleEnforcerTests.swift
Brendan Chen 32b1694e0a feat: re-skin to native three-tab UI and add Uninstall Protection
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>
2026-06-13 22:19:24 -04:00

340 lines
14 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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:0017: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:0017: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)
}
}