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.
113 lines
4.1 KiB
Swift
113 lines
4.1 KiB
Swift
//
|
||
// RuleStatusTests.swift
|
||
// SeveredTests
|
||
//
|
||
|
||
import Foundation
|
||
import Testing
|
||
|
||
@testable import Severed
|
||
|
||
@MainActor
|
||
@Suite("Rule status derivation and labels")
|
||
struct RuleStatusTests {
|
||
/// 09:00–17:00 weekdays schedule rule, like the video's "Work Time".
|
||
func workTime(hardMode: Bool = false) -> BlockingRule {
|
||
BlockingRule(name: "Work Time", hardMode: hardMode)
|
||
}
|
||
|
||
@Test("Disabled rules report disabled regardless of the clock")
|
||
func disabled() {
|
||
let rule = workTime()
|
||
rule.isEnabled = false
|
||
#expect(rule.status(at: date(2025, 1, 6, 10, 0), calendar: utc) == .disabled)
|
||
}
|
||
|
||
@Test("Enabled rule inside its window is active until the window ends")
|
||
func active() {
|
||
let status = workTime().status(at: date(2025, 1, 6, 10, 0), calendar: utc)
|
||
#expect(status == .active(until: date(2025, 1, 6, 17, 0)))
|
||
#expect(status.isActive)
|
||
}
|
||
|
||
@Test("Enabled rule outside its window is upcoming")
|
||
func upcoming() {
|
||
let status = workTime().status(at: date(2025, 1, 6, 18, 0), calendar: utc)
|
||
#expect(status == .upcoming(startsAt: date(2025, 1, 7, 9, 0)))
|
||
#expect(!status.isActive)
|
||
}
|
||
|
||
@Test("A rule with no days is dormant")
|
||
func dormant() {
|
||
let rule = workTime()
|
||
rule.days = []
|
||
#expect(rule.status(at: date(2025, 1, 6, 10, 0), calendar: utc) == .dormant)
|
||
}
|
||
|
||
@Test("A paused rule reports paused until the window ends")
|
||
func paused() {
|
||
let rule = workTime()
|
||
rule.pausedUntil = date(2025, 1, 6, 17, 0)
|
||
let status = rule.status(at: date(2025, 1, 6, 10, 0), calendar: utc)
|
||
#expect(status == .paused(until: date(2025, 1, 6, 17, 0)))
|
||
#expect(!status.isActive)
|
||
}
|
||
|
||
@Test("An expired pause no longer affects status")
|
||
func expiredPause() {
|
||
let rule = workTime()
|
||
rule.pausedUntil = date(2025, 1, 6, 9, 30)
|
||
let status = rule.status(at: date(2025, 1, 6, 10, 0), calendar: utc)
|
||
#expect(status == .active(until: date(2025, 1, 6, 17, 0)))
|
||
}
|
||
|
||
@Test("Time-limit rules are never schedule-active")
|
||
func timeLimitNeverActive() {
|
||
let rule = BlockingRule(name: "Time Keeper", kind: .timeLimit)
|
||
let status = rule.status(at: date(2025, 1, 6, 10, 0), calendar: utc)
|
||
#expect(!status.isActive)
|
||
if case .upcoming = status {} else {
|
||
Issue.record("Expected upcoming, got \(status)")
|
||
}
|
||
}
|
||
|
||
@Test("Active label matches the reference style: hours round up")
|
||
func activeLabel() {
|
||
// 11:28 → 17:00 is 5h32m; the reference app shows "6h left".
|
||
let status = workTime().status(at: date(2025, 1, 6, 11, 28), calendar: utc)
|
||
#expect(status.label(relativeTo: date(2025, 1, 6, 11, 28)) == "6h left")
|
||
}
|
||
|
||
@Test("Upcoming label matches the reference style")
|
||
func upcomingLabel() {
|
||
// Friday 11:28 → Saturday 09:00 is 21h32m; the reference shows "Starts in 22h".
|
||
let weekend = BlockingRule(name: "Weekend Zen", days: Weekday.weekends)
|
||
let friday = date(2025, 1, 10, 11, 28)
|
||
#expect(weekend.status(at: friday, calendar: utc).label(relativeTo: friday) == "Starts in 22h")
|
||
}
|
||
|
||
@Test("Countdown formatting tiers", arguments: [
|
||
(30, "30m"), (59, "59m"), (60, "1h"), (90, "2h"),
|
||
(6 * 60, "6h"), (47 * 60, "47h"), (49 * 60, "2d"), (75 * 60, "3d"),
|
||
])
|
||
func countdownTiers(minutes: Int, expected: String) {
|
||
let now = date(2025, 1, 6, 0, 0)
|
||
let target = utc.date(byAdding: .minute, value: minutes, to: now)!
|
||
#expect(RuleStatus.countdown(from: now, to: target) == expected)
|
||
}
|
||
|
||
@Test("Countdown never reads below one minute")
|
||
func countdownFloor() {
|
||
let now = date(2025, 1, 6, 0, 0)
|
||
#expect(RuleStatus.countdown(from: now, to: now.addingTimeInterval(5)) == "1m")
|
||
}
|
||
|
||
@Test("Static labels")
|
||
func staticLabels() {
|
||
let now = date(2025, 1, 6, 0, 0)
|
||
#expect(RuleStatus.disabled.label(relativeTo: now) == "Disabled")
|
||
#expect(RuleStatus.dormant.label(relativeTo: now) == "No days selected")
|
||
#expect(RuleStatus.paused(until: now).label(relativeTo: now) == "Paused")
|
||
}
|
||
}
|