feat: clone Opal's rules feature with hard block enforcement
Rebuild the app around recurring screen-time blocking rules modeled on Opal's My Apps tab: - Onboarding with FamilyControls Screen Time authorization - Apps home with Blocked Apps tiles and live rule cards - New Rule sheet: Schedule/Time Limit/Open Limit types + preset gallery - Rule editors: time windows (incl. overnight), day picker, app selection via FamilyActivityPicker (Block/Allow Only), rename, Hold to Commit - Hard Mode: active hard rules cannot be edited, disabled, deleted, or unblocked until their window ends; soft rules pause until next window - Shield enforcement through per-rule ManagedSettingsStore + RuleEnforcer - 73 unit tests (Swift Testing) + 16 UI tests (XCUITest) with launch-arg harness for in-memory storage, mocked authorization, seeded scenarios - docs/RULES_FEATURE_SPEC.md: spec derived from the reference recording Known gap: background window transitions and time/open-limit thresholds need a DeviceActivityMonitor extension; shields currently sync while the app is running.
This commit is contained in:
113
SeveredTests/RuleEnforcerTests.swift
Normal file
113
SeveredTests/RuleEnforcerTests.swift
Normal file
@@ -0,0 +1,113 @@
|
||||
//
|
||||
// RuleEnforcerTests.swift
|
||||
// SeveredTests
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import Severed
|
||||
|
||||
@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", kind: .timeLimit)
|
||||
|
||||
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", selectionMode: .allowOnly)
|
||||
|
||||
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
|
||||
|
||||
#expect(shields.appliedModes[rule.id] == .allowOnly)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user