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:
2026-06-12 12:35:52 -04:00
parent c760aa696f
commit 3aac2004d2
45 changed files with 3749 additions and 206 deletions

View File

@@ -0,0 +1,97 @@
//
// LaunchSupportTests.swift
// SeveredTests
//
import Foundation
import Testing
@testable import Severed
@MainActor
@Suite("Launch configuration parsing")
struct LaunchConfigurationTests {
@Test("Defaults are production settings")
func defaults() {
let config = LaunchConfiguration.parse(arguments: ["SeveredApp"])
#expect(!config.isUITesting)
#expect(config.onboardingCompleted == nil)
#expect(config.seedScenario == nil)
}
@Test("UI testing flags are recognized")
func uiTestingFlags() {
let config = LaunchConfiguration.parse(arguments: [
"SeveredApp", "-ui-testing", "-onboarding-completed", "-seed-scenario=standard",
])
#expect(config.isUITesting)
#expect(config.onboardingCompleted == true)
#expect(config.seedScenario == .standard)
}
@Test("Onboarding can be forced on")
func onboardingRequired() {
let config = LaunchConfiguration.parse(arguments: ["SeveredApp", "-onboarding-required"])
#expect(config.onboardingCompleted == false)
}
@Test("Unknown seed scenarios are ignored")
func unknownScenario() {
let config = LaunchConfiguration.parse(arguments: ["SeveredApp", "-seed-scenario=nope"])
#expect(config.seedScenario == nil)
}
}
@MainActor
@Suite("Seeded sample rules")
struct SampleRulesTests {
@Test(
"Seeded active rules are genuinely active at any time of day",
arguments: [(0, 30), (9, 0), (12, 30), (23, 50)]
)
func activeRuleIsActive(hour: Int, minute: Int) {
let now = date(2025, 1, 6, hour, minute)
let rule = SampleRules.activeRule(named: "Work Time", hardMode: false, now: now, calendar: utc)
#expect(rule.status(at: now, calendar: utc).isActive)
}
@Test(
"Seeded upcoming rules are not active but will start",
arguments: [(0, 30), (9, 0), (12, 30), (23, 50)]
)
func upcomingRuleIsUpcoming(hour: Int, minute: Int) {
let now = date(2025, 1, 6, hour, minute)
let rule = SampleRules.upcomingRule(named: "Sleep", now: now, calendar: utc)
let status = rule.status(at: now, calendar: utc)
#expect(!status.isActive)
if case .upcoming = status {} else {
Issue.record("Expected upcoming, got \(status)")
}
}
@Test("Hard mode scenario seeds a locked active rule")
func hardModeScenario() {
let now = date(2025, 1, 6, 12, 0)
let rule = SampleRules.activeRule(named: "Locked In", hardMode: true, now: now, calendar: utc)
#expect(RulePolicy.isHardLocked(rule, at: now, calendar: utc))
}
@Test("Mock authorization approves on request")
func mockAuthorization() async {
let provider = MockAuthorizationProvider()
let auth = ScreenTimeAuthorization(provider: provider)
#expect(auth.status == .notDetermined)
await auth.request()
#expect(auth.status == .approved)
#expect(!auth.lastRequestFailed)
}
@Test("Mock authorization can simulate denial")
func mockAuthorizationDenied() async {
let provider = MockAuthorizationProvider(requestShouldFail: true)
let auth = ScreenTimeAuthorization(provider: provider)
await auth.request()
#expect(auth.status == .denied)
#expect(auth.lastRequestFailed)
}
}