Replace the wide BlockingRule/RuleDraft "god struct" — where every option existed for every kind — with a RuleConfiguration sum type that carries only the options each kind actually has: .schedule(ScheduleConfig: window + selectionMode + blockAdultContent) .timeLimit(TimeLimitConfig: dailyLimitMinutes) .openLimit(OpenLimitConfig: maxOpens) Name, days, Hard Mode, app list, and pause stay common to all kinds. This makes illegal states unrepresentable: Block / Allow Only and Block Adult Content are now structurally Schedule-only. User-visible behavior change: the Time Limit and Open Limit editors no longer offer a Block Adult Content toggle, and their detail sheets drop the "Adult websites" row — those never made sense for a usage budget. Limit rules are always Block and never engage the web-content filter. BlockingRule keeps flat columns as raw persistence behind a computed `configuration` bridge (lowest SwiftData risk; the cross-process RuleSnapshot wire format and the Screen Time extensions are untouched). Logic, the editors, and the detail sheet all switch on the sum type. Spec (§1, §3.5/§3.6, §5.2) updated first; tests reworked to the new API with new structural-guarantee unit tests and a UI test asserting the Time Limit editor/detail omit adult content. Full suite green (180 tests).
129 lines
4.6 KiB
Swift
129 lines
4.6 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("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)
|
|
}
|
|
}
|