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:
97
SeveredTests/LaunchSupportTests.swift
Normal file
97
SeveredTests/LaunchSupportTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
148
SeveredTests/RuleModelTests.swift
Normal file
148
SeveredTests/RuleModelTests.swift
Normal file
@@ -0,0 +1,148 @@
|
||||
//
|
||||
// RuleModelTests.swift
|
||||
// SeveredTests
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
import Testing
|
||||
|
||||
@testable import Severed
|
||||
|
||||
@MainActor
|
||||
@Suite("BlockingRule model & persistence")
|
||||
struct RuleModelTests {
|
||||
@Test("Defaults match the reference app's new-rule defaults")
|
||||
func defaults() {
|
||||
let rule = BlockingRule(name: "In the Zone")
|
||||
#expect(rule.kind == .schedule)
|
||||
#expect(rule.isEnabled)
|
||||
#expect(!rule.hardMode)
|
||||
#expect(rule.selectionMode == .block)
|
||||
#expect(rule.days == Weekday.weekdays)
|
||||
#expect(rule.startMinutes == 9 * 60)
|
||||
#expect(rule.endMinutes == 17 * 60)
|
||||
#expect(rule.pausedUntil == nil)
|
||||
}
|
||||
|
||||
@Test("Days survive the raw-storage round trip")
|
||||
func daysRoundTrip() {
|
||||
let rule = BlockingRule(name: "Test")
|
||||
rule.days = [.sunday, .wednesday, .saturday]
|
||||
#expect(rule.days == [.sunday, .wednesday, .saturday])
|
||||
#expect(rule.dayNumbers == [1, 4, 7])
|
||||
}
|
||||
|
||||
@Test("Kind and selection mode survive raw storage, with safe fallbacks")
|
||||
func enumRoundTrip() {
|
||||
let rule = BlockingRule(name: "Test", kind: .openLimit, selectionMode: .allowOnly)
|
||||
#expect(rule.kind == .openLimit)
|
||||
#expect(rule.selectionMode == .allowOnly)
|
||||
rule.kindRaw = "garbage"
|
||||
rule.selectionModeRaw = "garbage"
|
||||
#expect(rule.kind == .schedule)
|
||||
#expect(rule.selectionMode == .block)
|
||||
}
|
||||
|
||||
@Test("Rules persist and fetch through SwiftData")
|
||||
func persistence() throws {
|
||||
let container = try ModelContainer(
|
||||
for: BlockingRule.self,
|
||||
configurations: ModelConfiguration(isStoredInMemoryOnly: true)
|
||||
)
|
||||
let context = container.mainContext
|
||||
let rule = BlockingRule(
|
||||
name: "Deep Sleep", hardMode: true,
|
||||
days: Weekday.everyDay, startMinutes: 22 * 60, endMinutes: 6 * 60
|
||||
)
|
||||
context.insert(rule)
|
||||
try context.save()
|
||||
|
||||
let fetched = try context.fetch(FetchDescriptor<BlockingRule>())
|
||||
#expect(fetched.count == 1)
|
||||
let saved = try #require(fetched.first)
|
||||
#expect(saved.name == "Deep Sleep")
|
||||
#expect(saved.hardMode)
|
||||
#expect(saved.days == Weekday.everyDay)
|
||||
#expect(saved.schedule.crossesMidnight)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Suite("RuleDraft")
|
||||
struct RuleDraftTests {
|
||||
@Test("New drafts use the kind's defaults")
|
||||
func newDraftDefaults() {
|
||||
let draft = RuleDraft(kind: .timeLimit)
|
||||
#expect(draft.name == "Time Keeper")
|
||||
#expect(draft.kind == .timeLimit)
|
||||
#expect(draft.dailyLimitMinutes == 45)
|
||||
#expect(draft.days == Weekday.weekdays)
|
||||
#expect(!draft.hardMode)
|
||||
}
|
||||
|
||||
@Test("Draft → rule → draft round-trips every field")
|
||||
func roundTrip() {
|
||||
var draft = RuleDraft(kind: .schedule)
|
||||
draft.name = "Locked In"
|
||||
draft.days = Weekday.everyDay
|
||||
draft.startMinutes = 22 * 60
|
||||
draft.endMinutes = 6 * 60
|
||||
draft.hardMode = true
|
||||
draft.selectionMode = .allowOnly
|
||||
draft.selectionCount = 3
|
||||
|
||||
let rule = draft.makeRule()
|
||||
#expect(RuleDraft(rule: rule) == draft)
|
||||
}
|
||||
|
||||
@Test("Applying a draft updates an existing rule")
|
||||
func applyToExisting() {
|
||||
let rule = BlockingRule(name: "Old Name")
|
||||
var draft = RuleDraft(rule: rule)
|
||||
draft.name = "New Name"
|
||||
draft.hardMode = true
|
||||
draft.apply(to: rule)
|
||||
#expect(rule.name == "New Name")
|
||||
#expect(rule.hardMode)
|
||||
}
|
||||
|
||||
@Test("Preset drafts copy the preset's schedule")
|
||||
func presetDraft() throws {
|
||||
let preset = try #require(
|
||||
RulePresetSection.all
|
||||
.flatMap(\.presets)
|
||||
.first { $0.id == "deep-sleep" }
|
||||
)
|
||||
let draft = RuleDraft(preset: preset)
|
||||
#expect(draft.name == "Deep Sleep")
|
||||
#expect(draft.startMinutes == 22 * 60)
|
||||
#expect(draft.endMinutes == 6 * 60)
|
||||
#expect(draft.kind == .schedule)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Suite("Weekday summaries")
|
||||
struct WeekdayTests {
|
||||
@Test("Named sets")
|
||||
func namedSets() {
|
||||
#expect(Weekday.weekdays.summary == "Weekdays")
|
||||
#expect(Weekday.weekends.summary == "Weekends")
|
||||
#expect(Weekday.everyDay.summary == "Every day")
|
||||
#expect(Set<Weekday>().summary == "Never")
|
||||
}
|
||||
|
||||
@Test("Custom sets list days in display order")
|
||||
func customSets() {
|
||||
let days: Set<Weekday> = [.friday, .monday, .wednesday]
|
||||
#expect(days.summary == "Mon, Wed, Fri")
|
||||
}
|
||||
|
||||
@Test("Picker display order starts on Sunday")
|
||||
func displayOrder() {
|
||||
#expect(Weekday.displayOrder.first == .sunday)
|
||||
#expect(Weekday.displayOrder.count == 7)
|
||||
#expect(Weekday.displayOrder.map(\.shortLabel) == ["S", "M", "T", "W", "T", "F", "S"])
|
||||
}
|
||||
}
|
||||
81
SeveredTests/RulePolicyTests.swift
Normal file
81
SeveredTests/RulePolicyTests.swift
Normal file
@@ -0,0 +1,81 @@
|
||||
//
|
||||
// RulePolicyTests.swift
|
||||
// SeveredTests
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import Severed
|
||||
|
||||
@MainActor
|
||||
@Suite("Hard Mode policy")
|
||||
struct RulePolicyTests {
|
||||
let mondayDuringWork = date(2025, 1, 6, 10, 0)
|
||||
let mondayEvening = date(2025, 1, 6, 19, 0)
|
||||
|
||||
func rule(hardMode: Bool) -> BlockingRule {
|
||||
BlockingRule(name: "Work Time", hardMode: hardMode)
|
||||
}
|
||||
|
||||
@Test("An active Hard Mode rule is locked")
|
||||
func hardLockedWhileActive() {
|
||||
let rule = rule(hardMode: true)
|
||||
#expect(RulePolicy.isHardLocked(rule, at: mondayDuringWork, calendar: utc))
|
||||
#expect(!RulePolicy.canEdit(rule, at: mondayDuringWork, calendar: utc))
|
||||
#expect(!RulePolicy.canDisable(rule, at: mondayDuringWork, calendar: utc))
|
||||
#expect(!RulePolicy.canDelete(rule, at: mondayDuringWork, calendar: utc))
|
||||
#expect(!RulePolicy.canUnblock(rule, at: mondayDuringWork, calendar: utc))
|
||||
#expect(!RulePolicy.canTurnOffHardMode(rule, at: mondayDuringWork, calendar: utc))
|
||||
}
|
||||
|
||||
@Test("A Hard Mode rule unlocks once its window ends")
|
||||
func unlockedOutsideWindow() {
|
||||
let rule = rule(hardMode: true)
|
||||
#expect(!RulePolicy.isHardLocked(rule, at: mondayEvening, calendar: utc))
|
||||
#expect(RulePolicy.canEdit(rule, at: mondayEvening, calendar: utc))
|
||||
#expect(RulePolicy.canDisable(rule, at: mondayEvening, calendar: utc))
|
||||
#expect(RulePolicy.canDelete(rule, at: mondayEvening, calendar: utc))
|
||||
#expect(RulePolicy.canTurnOffHardMode(rule, at: mondayEvening, calendar: utc))
|
||||
}
|
||||
|
||||
@Test("A disabled Hard Mode rule is not locked")
|
||||
func disabledRuleNotLocked() {
|
||||
let rule = rule(hardMode: true)
|
||||
rule.isEnabled = false
|
||||
#expect(!RulePolicy.isHardLocked(rule, at: mondayDuringWork, calendar: utc))
|
||||
#expect(RulePolicy.canEdit(rule, at: mondayDuringWork, calendar: utc))
|
||||
}
|
||||
|
||||
@Test("Active non-Hard-Mode rules may be unblocked")
|
||||
func softRuleUnblockable() {
|
||||
let rule = rule(hardMode: false)
|
||||
#expect(RulePolicy.canUnblock(rule, at: mondayDuringWork, calendar: utc))
|
||||
}
|
||||
|
||||
@Test("Unblocking pauses the rule until its window ends")
|
||||
func unblockPausesUntilWindowEnd() {
|
||||
let rule = rule(hardMode: false)
|
||||
let didUnblock = RulePolicy.unblock(rule, at: mondayDuringWork, calendar: utc)
|
||||
#expect(didUnblock)
|
||||
#expect(rule.pausedUntil == date(2025, 1, 6, 17, 0))
|
||||
#expect(rule.status(at: mondayDuringWork, calendar: utc)
|
||||
== .paused(until: date(2025, 1, 6, 17, 0)))
|
||||
}
|
||||
|
||||
@Test("Unblocking a Hard Mode rule is refused and changes nothing")
|
||||
func hardModeUnblockRefused() {
|
||||
let rule = rule(hardMode: true)
|
||||
let didUnblock = RulePolicy.unblock(rule, at: mondayDuringWork, calendar: utc)
|
||||
#expect(!didUnblock)
|
||||
#expect(rule.pausedUntil == nil)
|
||||
#expect(rule.status(at: mondayDuringWork, calendar: utc).isActive)
|
||||
}
|
||||
|
||||
@Test("Unblocking an inactive rule is refused")
|
||||
func inactiveUnblockRefused() {
|
||||
let rule = rule(hardMode: false)
|
||||
#expect(!RulePolicy.unblock(rule, at: mondayEvening, calendar: utc))
|
||||
#expect(rule.pausedUntil == nil)
|
||||
}
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
112
SeveredTests/RuleStatusTests.swift
Normal file
112
SeveredTests/RuleStatusTests.swift
Normal file
@@ -0,0 +1,112 @@
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
//
|
||||
// SeveredTests.swift
|
||||
// SeveredTests
|
||||
//
|
||||
// Created by Brendan Chen on 2025.08.09.
|
||||
//
|
||||
|
||||
import Testing
|
||||
|
||||
struct SeveredTests {
|
||||
|
||||
@Test func example() async throws {
|
||||
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
|
||||
}
|
||||
|
||||
}
|
||||
36
SeveredTests/TestSupport.swift
Normal file
36
SeveredTests/TestSupport.swift
Normal file
@@ -0,0 +1,36 @@
|
||||
//
|
||||
// TestSupport.swift
|
||||
// SeveredTests
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
@testable import Severed
|
||||
|
||||
/// Fixed UTC gregorian calendar so schedule math is deterministic regardless
|
||||
/// of the machine running the tests.
|
||||
let utc: Calendar = {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone(identifier: "UTC")!
|
||||
return calendar
|
||||
}()
|
||||
|
||||
/// Builds a date in the UTC test calendar. 2025-01-06 is a Monday; the tests
|
||||
/// use that week (Jan 6–12, 2025) as their anchor.
|
||||
func date(
|
||||
_ year: Int, _ month: Int, _ day: Int, _ hour: Int = 0, _ minute: Int = 0
|
||||
) -> Date {
|
||||
utc.date(
|
||||
from: DateComponents(year: year, month: month, day: day, hour: hour, minute: minute)
|
||||
)!
|
||||
}
|
||||
|
||||
/// Monday of the anchor week.
|
||||
enum AnchorWeek {
|
||||
static let monday = (year: 2025, month: 1, day: 6)
|
||||
static let tuesday = (year: 2025, month: 1, day: 7)
|
||||
static let wednesday = (year: 2025, month: 1, day: 8)
|
||||
static let saturday = (year: 2025, month: 1, day: 11)
|
||||
static let sunday = (year: 2025, month: 1, day: 12)
|
||||
static let nextMonday = (year: 2025, month: 1, day: 13)
|
||||
}
|
||||
Reference in New Issue
Block a user