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.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-12 12:35:52 -04:00
parent 0c03e35ea9
commit e6c87baeba
45 changed files with 3749 additions and 206 deletions

View File

@@ -0,0 +1,121 @@
//
// RuleScheduleTests.swift
// SeveredTests
//
import Foundation
import Testing
@testable import Severed
@MainActor
@Suite("RuleSchedule window math")
struct RuleScheduleTests {
let nineToFiveWeekdays = RuleSchedule(
startMinutes: 9 * 60, endMinutes: 17 * 60, days: Weekday.weekdays
)
let overnight = RuleSchedule(
startMinutes: 22 * 60, endMinutes: 6 * 60, days: Weekday.everyDay
)
@Test("Active inside a same-day window")
func activeInsideWindow() {
let monday10am = date(2025, 1, 6, 10, 0)
let window = nineToFiveWeekdays.activeWindow(containing: monday10am, calendar: utc)
#expect(window?.start == date(2025, 1, 6, 9, 0))
#expect(window?.end == date(2025, 1, 6, 17, 0))
}
@Test("Inactive before the window starts and after it ends")
func inactiveOutsideWindow() {
#expect(!nineToFiveWeekdays.isActive(at: date(2025, 1, 6, 8, 59), calendar: utc))
#expect(!nineToFiveWeekdays.isActive(at: date(2025, 1, 6, 17, 0), calendar: utc))
}
@Test("Window start is inclusive, end is exclusive")
func boundaryInclusion() {
#expect(nineToFiveWeekdays.isActive(at: date(2025, 1, 6, 9, 0), calendar: utc))
#expect(!nineToFiveWeekdays.isActive(at: date(2025, 1, 6, 17, 0), calendar: utc))
}
@Test("Inactive on a disabled day")
func inactiveOnWeekend() {
#expect(!nineToFiveWeekdays.isActive(at: date(2025, 1, 11, 12, 0), calendar: utc))
}
@Test("Overnight window is active before and after midnight")
func overnightWindow() {
// Monday 23:30 inside Monday's 22:00 Tuesday 06:00 window.
let lateMonday = overnight.activeWindow(containing: date(2025, 1, 6, 23, 30), calendar: utc)
#expect(lateMonday?.start == date(2025, 1, 6, 22, 0))
#expect(lateMonday?.end == date(2025, 1, 7, 6, 0))
// Tuesday 02:00 still inside the window that started Monday.
let earlyTuesday = overnight.activeWindow(containing: date(2025, 1, 7, 2, 0), calendar: utc)
#expect(earlyTuesday?.start == date(2025, 1, 6, 22, 0))
#expect(earlyTuesday?.end == date(2025, 1, 7, 6, 0))
// The end boundary is exclusive: Tuesday 06:00 is outside Monday's window.
#expect(overnight.activeWindow(containing: date(2025, 1, 7, 6, 0), calendar: utc) == nil)
}
@Test("Overnight window belongs to the day it starts on")
func overnightDayOwnership() {
let mondayOnly = RuleSchedule(
startMinutes: 22 * 60, endMinutes: 6 * 60, days: [.monday]
)
// Tuesday 03:00 is inside Monday's window.
#expect(mondayOnly.isActive(at: date(2025, 1, 7, 3, 0), calendar: utc))
// Wednesday 03:00 would be Tuesday's window Tuesday is disabled.
#expect(!mondayOnly.isActive(at: date(2025, 1, 8, 3, 0), calendar: utc))
// Monday 23:00 is inside Monday's window.
#expect(mondayOnly.isActive(at: date(2025, 1, 6, 23, 0), calendar: utc))
}
@Test("Equal start and end means a 24-hour window")
func fullDayWindow() {
let allDay = RuleSchedule(startMinutes: 600, endMinutes: 600, days: [.monday])
#expect(allDay.durationMinutes == 24 * 60)
#expect(allDay.isActive(at: date(2025, 1, 7, 9, 59), calendar: utc))
#expect(!allDay.isActive(at: date(2025, 1, 7, 10, 0), calendar: utc))
}
@Test("Next start on the same day")
func nextStartSameDay() {
let next = nineToFiveWeekdays.nextStart(after: date(2025, 1, 6, 8, 0), calendar: utc)
#expect(next == date(2025, 1, 6, 9, 0))
}
@Test("Next start skips disabled days")
func nextStartSkipsWeekend() {
let next = nineToFiveWeekdays.nextStart(after: date(2025, 1, 11, 12, 0), calendar: utc)
#expect(next == date(2025, 1, 13, 9, 0))
}
@Test("Next start is strictly after the given moment")
func nextStartStrictlyAfter() {
let next = nineToFiveWeekdays.nextStart(after: date(2025, 1, 6, 9, 0), calendar: utc)
#expect(next == date(2025, 1, 7, 9, 0))
}
@Test("No days means no windows and no next start")
func emptyDays() {
let never = RuleSchedule(startMinutes: 540, endMinutes: 1020, days: [])
#expect(!never.isActive(at: date(2025, 1, 6, 10, 0), calendar: utc))
#expect(never.nextStart(after: date(2025, 1, 6, 10, 0), calendar: utc) == nil)
}
@Test("Duration handles normal and overnight windows")
func duration() {
#expect(nineToFiveWeekdays.durationMinutes == 8 * 60)
#expect(overnight.durationMinutes == 8 * 60)
}
@Test("Time labels are zero-padded 24h")
func timeLabels() {
#expect(RuleSchedule.timeLabel(forMinutes: 9 * 60) == "09:00")
#expect(RuleSchedule.timeLabel(forMinutes: 17 * 60 + 5) == "17:05")
#expect(RuleSchedule.timeLabel(forMinutes: 0) == "00:00")
#expect(nineToFiveWeekdays.timeRangeLabel == "09:00 17:00")
}
}