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:
121
SeveredTests/RuleScheduleTests.swift
Normal file
121
SeveredTests/RuleScheduleTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user