refactor: model rule options as a per-kind sum type

Replace the wide BlockingRule/RuleDraft "god struct" — where every option
existed for every kind — with a RuleConfiguration sum type that carries only
the options each kind actually has:

  .schedule(ScheduleConfig: window + selectionMode + blockAdultContent)
  .timeLimit(TimeLimitConfig: dailyLimitMinutes)
  .openLimit(OpenLimitConfig: maxOpens)

Name, days, Hard Mode, app list, and pause stay common to all kinds. This
makes illegal states unrepresentable: Block / Allow Only and Block Adult
Content are now structurally Schedule-only.

User-visible behavior change: the Time Limit and Open Limit editors no longer
offer a Block Adult Content toggle, and their detail sheets drop the "Adult
websites" row — those never made sense for a usage budget. Limit rules are
always Block and never engage the web-content filter.

BlockingRule keeps flat columns as raw persistence behind a computed
`configuration` bridge (lowest SwiftData risk; the cross-process RuleSnapshot
wire format and the Screen Time extensions are untouched). Logic, the editors,
and the detail sheet all switch on the sum type.

Spec (§1, §3.5/§3.6, §5.2) updated first; tests reworked to the new API with
new structural-guarantee unit tests and a UI test asserting the Time Limit
editor/detail omit adult content. Full suite green (180 tests).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-06-13 12:49:16 -04:00
parent c4969b05ec
commit 783a8571a7
20 changed files with 612 additions and 258 deletions

View File

@@ -86,10 +86,21 @@ struct AppListModelTests {
@MainActor
@Suite("Legacy selection → AppList migration")
struct AppListMigrationTests {
/// Simulates a rule decoded from a pre-app-list store: a plain rule whose
/// legacy inline-selection columns are populated.
private func legacyRule(name: String, selectionData: Data, selectionCount: Int) -> BlockingRule {
let rule = BlockingRule(name: name)
rule.selectionData = selectionData
rule.selectionCount = selectionCount
return rule
}
@Test("Rules with legacy inline selections get a list named after them")
func createsListsFromLegacySelections() throws {
let context = try makeInMemoryContext()
let rule = BlockingRule(name: "Work Time", selectionData: Data([1]), selectionCount: 3)
let rule = BlockingRule(name: "Work Time")
rule.selectionData = Data([1])
rule.selectionCount = 3
context.insert(rule)
try context.save()
@@ -108,9 +119,9 @@ struct AppListMigrationTests {
@Test("Rules with identical selections share one list")
func sharesListForIdenticalSelections() throws {
let context = try makeInMemoryContext()
let first = BlockingRule(name: "Work Time", selectionData: Data([7]), selectionCount: 2)
let second = BlockingRule(name: "Sleep", selectionData: Data([7]), selectionCount: 2)
let different = BlockingRule(name: "Gym", selectionData: Data([9]), selectionCount: 1)
let first = legacyRule(name: "Work Time", selectionData: Data([7]), selectionCount: 2)
let second = legacyRule(name: "Sleep", selectionData: Data([7]), selectionCount: 2)
let different = legacyRule(name: "Gym", selectionData: Data([9]), selectionCount: 1)
context.insert(first)
context.insert(second)
context.insert(different)
@@ -127,7 +138,7 @@ struct AppListMigrationTests {
@Test("Migration is idempotent and skips selection-less rules")
func idempotentAndSkipsEmpty() throws {
let context = try makeInMemoryContext()
let legacy = BlockingRule(name: "Work Time", selectionData: Data([1]), selectionCount: 1)
let legacy = legacyRule(name: "Work Time", selectionData: Data([1]), selectionCount: 1)
let empty = BlockingRule(name: "No Apps")
context.insert(legacy)
context.insert(empty)
@@ -163,22 +174,24 @@ struct AppListDraftTests {
#expect(other.name == "Other")
}
@Test("Sanitizing forces Block mode for time- and open-limit rules")
func sanitizedForcesBlockForLimitKinds() {
var timeDraft = RuleDraft(kind: .timeLimit)
timeDraft.selectionMode = .allowOnly
#expect(timeDraft.sanitized().selectionMode == .block)
@Test("Limit drafts structurally cannot carry a selection mode")
func limitDraftsHaveNoSelectionMode() {
// The sum type makes Block / Allow Only a Schedule-only option: a limit
// draft's configuration has no selection mode to force back to Block.
#expect(RuleDraft(kind: .timeLimit).configuration.scheduleConfig == nil)
#expect(RuleDraft(kind: .openLimit).configuration.scheduleConfig == nil)
var openDraft = RuleDraft(kind: .openLimit)
openDraft.selectionMode = .allowOnly
#expect(openDraft.sanitized().selectionMode == .block)
// And the rule built from a limit draft is always Block.
let rule = BlockingRule(
name: "Time Keeper", configuration: RuleDraft(kind: .timeLimit).configuration)
#expect(rule.selectionMode == .block)
}
@Test("Sanitizing keeps Allow Only on schedule rules")
func sanitizedKeepsAllowOnlyForSchedule() {
@Test("Allow Only survives on schedule rules")
func keepsAllowOnlyForSchedule() {
var draft = RuleDraft(kind: .schedule)
draft.selectionMode = .allowOnly
#expect(draft.sanitized().selectionMode == .allowOnly)
draft.scheduleConfig.selectionMode = .allowOnly
#expect(draft.sanitized().scheduleConfig.selectionMode == .allowOnly)
}
}

View File

@@ -0,0 +1,44 @@
//
// RuleConfigurationTests.swift
// OpenAppLockTests
//
import Foundation
import Testing
@testable import OpenAppLock
@MainActor
@Suite("RuleConfiguration sum type")
struct RuleConfigurationTests {
@Test("Each case reports its kind")
func kindDerivation() {
#expect(RuleConfiguration.schedule(ScheduleConfig()).kind == .schedule)
#expect(RuleConfiguration.timeLimit(TimeLimitConfig()).kind == .timeLimit)
#expect(RuleConfiguration.openLimit(OpenLimitConfig()).kind == .openLimit)
}
@Test("Defaults match the reference app's new-rule defaults")
func defaults() {
let schedule = RuleConfiguration.default(for: .schedule).scheduleConfig
#expect(schedule?.startMinutes == 9 * 60)
#expect(schedule?.endMinutes == 17 * 60)
#expect(schedule?.selectionMode == .block)
#expect(schedule?.blockAdultContent == false)
#expect(RuleConfiguration.default(for: .timeLimit).timeLimitConfig?.dailyLimitMinutes == 45)
#expect(RuleConfiguration.default(for: .openLimit).openLimitConfig?.maxOpens == 5)
}
@Test("Typed projections only unwrap the matching case")
func projections() {
let schedule = RuleConfiguration.schedule(ScheduleConfig(blockAdultContent: true))
#expect(schedule.scheduleConfig?.blockAdultContent == true)
#expect(schedule.timeLimitConfig == nil)
#expect(schedule.openLimitConfig == nil)
let openLimit = RuleConfiguration.openLimit(OpenLimitConfig(maxOpens: 7))
#expect(openLimit.openLimitConfig?.maxOpens == 7)
#expect(openLimit.scheduleConfig == nil)
}
}

View File

@@ -54,7 +54,7 @@ struct RuleEnforcerTests {
func skipsTimeLimitRules() {
let shields = MockShieldController()
let enforcer = RuleEnforcer(shields: shields)
let rule = BlockingRule(name: "Time Keeper", kind: .timeLimit)
let rule = BlockingRule(name: "Time Keeper", configuration: .timeLimit(TimeLimitConfig()))
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
@@ -104,7 +104,8 @@ struct RuleEnforcerTests {
func forwardsSelectionMode() {
let shields = MockShieldController()
let enforcer = RuleEnforcer(shields: shields)
let rule = BlockingRule(name: "Focus", selectionMode: .allowOnly)
let rule = BlockingRule(
name: "Focus", configuration: .schedule(ScheduleConfig(selectionMode: .allowOnly)))
enforcer.refresh(rules: [rule], at: mondayDuringWork, calendar: utc)
@@ -115,7 +116,8 @@ struct RuleEnforcerTests {
func forwardsAdultContentFlag() {
let shields = MockShieldController()
let enforcer = RuleEnforcer(shields: shields)
let filtered = BlockingRule(name: "Clean Mode", blockAdultContent: true)
let filtered = BlockingRule(
name: "Clean Mode", configuration: .schedule(ScheduleConfig(blockAdultContent: true)))
let unfiltered = BlockingRule(name: "Plain")
enforcer.refresh(rules: [filtered, unfiltered], at: mondayDuringWork, calendar: utc)

View File

@@ -36,8 +36,9 @@ struct RuleModelTests {
@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)
let rule = BlockingRule(
name: "Test", configuration: .schedule(ScheduleConfig(selectionMode: .allowOnly)))
#expect(rule.kind == .schedule)
#expect(rule.selectionMode == .allowOnly)
rule.kindRaw = "garbage"
rule.selectionModeRaw = "garbage"
@@ -45,12 +46,43 @@ struct RuleModelTests {
#expect(rule.selectionMode == .block)
}
@Test("Limit kinds can never carry Schedule-only options")
func limitKindsHaveNoScheduleOptions() {
let timeLimit = BlockingRule(
name: "Time Keeper", configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 30)))
#expect(timeLimit.kind == .timeLimit)
#expect(timeLimit.selectionMode == .block)
#expect(!timeLimit.blockAdultContent)
#expect(timeLimit.dailyLimitMinutes == 30)
let openLimit = BlockingRule(
name: "Gate Keeper", configuration: .openLimit(OpenLimitConfig(maxOpens: 3)))
#expect(openLimit.kind == .openLimit)
#expect(openLimit.selectionMode == .block)
#expect(!openLimit.blockAdultContent)
#expect(openLimit.maxOpens == 3)
}
@Test("Configuration round-trips through the model's raw storage")
func configurationRoundTrip() {
let config = RuleConfiguration.schedule(
ScheduleConfig(
startMinutes: 22 * 60, endMinutes: 6 * 60,
selectionMode: .allowOnly, blockAdultContent: true))
let rule = BlockingRule(name: "Deep Sleep", configuration: config)
#expect(rule.configuration == config)
#expect(rule.startMinutes == 22 * 60)
#expect(rule.blockAdultContent)
}
@Test("Rules persist and fetch through SwiftData")
func persistence() throws {
let context = try makeInMemoryContext()
let rule = BlockingRule(
name: "Deep Sleep", hardMode: true,
days: Weekday.everyDay, startMinutes: 22 * 60, endMinutes: 6 * 60
name: "Deep Sleep",
configuration: .schedule(ScheduleConfig(startMinutes: 22 * 60, endMinutes: 6 * 60)),
hardMode: true,
days: Weekday.everyDay
)
context.insert(rule)
try context.save()
@@ -73,7 +105,7 @@ struct RuleDraftTests {
let draft = RuleDraft(kind: .timeLimit)
#expect(draft.name == "Time Keeper")
#expect(draft.kind == .timeLimit)
#expect(draft.dailyLimitMinutes == 45)
#expect(draft.timeLimitConfig.dailyLimitMinutes == 45)
#expect(draft.days == Weekday.weekdays)
#expect(!draft.hardMode)
}
@@ -87,18 +119,32 @@ struct RuleDraftTests {
var draft = RuleDraft(kind: .schedule)
draft.name = "Locked In"
draft.days = Weekday.everyDay
draft.startMinutes = 22 * 60
draft.endMinutes = 6 * 60
draft.configuration = .schedule(
ScheduleConfig(
startMinutes: 22 * 60, endMinutes: 6 * 60,
selectionMode: .allowOnly, blockAdultContent: true))
draft.hardMode = true
draft.blockAdultContent = true
draft.selectionMode = .allowOnly
draft.appList = list
let rule = draft.insertRule(into: context)
#expect(rule.blockAdultContent)
#expect(rule.selectionMode == .allowOnly)
#expect(RuleDraft(rule: rule) == draft)
}
@Test("A limit draft cannot carry Schedule-only options")
func limitDraftHasNoScheduleOptions() {
let draft = RuleDraft(kind: .openLimit)
// The configuration is an open-limit case, so there is structurally no
// selection mode or adult-content flag to set.
#expect(draft.configuration.scheduleConfig == nil)
#expect(draft.openLimitConfig.maxOpens == 5)
let rule = BlockingRule(name: draft.name, configuration: draft.configuration)
#expect(!rule.blockAdultContent)
#expect(rule.selectionMode == .block)
}
@Test("Applying a draft updates an existing rule")
func applyToExisting() throws {
let context = try makeInMemoryContext()
@@ -135,8 +181,8 @@ struct RuleDraftTests {
)
let draft = RuleDraft(preset: preset)
#expect(draft.name == "Deep Sleep")
#expect(draft.startMinutes == 22 * 60)
#expect(draft.endMinutes == 6 * 60)
#expect(draft.scheduleConfig.startMinutes == 22 * 60)
#expect(draft.scheduleConfig.endMinutes == 6 * 60)
#expect(draft.kind == .schedule)
}
}

View File

@@ -63,7 +63,7 @@ struct RuleStatusTests {
@Test("Time-limit rules are never schedule-active")
func timeLimitNeverActive() {
let rule = BlockingRule(name: "Time Keeper", kind: .timeLimit)
let rule = BlockingRule(name: "Time Keeper", configuration: .timeLimit(TimeLimitConfig()))
let status = rule.status(at: date(2025, 1, 6, 10, 0), calendar: utc)
#expect(!status.isActive)
if case .upcoming = status {} else {
@@ -116,7 +116,8 @@ struct RuleStatusTests {
/// daily budget never the vestigial 09:00 start as "Starts in 22h".
@Test("Idle time-limit rule shows its daily budget, not a clock countdown")
func timeLimitDisplayLabel() {
let rule = BlockingRule(name: "Time Keeper", kind: .timeLimit, dailyLimitMinutes: 15)
let rule = BlockingRule(
name: "Time Keeper", configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 15)))
let now = date(2025, 1, 6, 11, 38) // past the vestigial 09:00 window start
let status = rule.status(at: now, calendar: utc)
#expect(rule.statusLabel(for: status, relativeTo: now) == "15m / day")
@@ -124,7 +125,8 @@ struct RuleStatusTests {
@Test("Idle open-limit rule shows its daily opens budget")
func openLimitDisplayLabel() {
let rule = BlockingRule(name: "Gate Keeper", kind: .openLimit, maxOpens: 5)
let rule = BlockingRule(
name: "Gate Keeper", configuration: .openLimit(OpenLimitConfig(maxOpens: 5)))
let now = date(2025, 1, 6, 11, 38)
let status = rule.status(at: now, calendar: utc)
#expect(rule.statusLabel(for: status, relativeTo: now) == "5 opens / day")
@@ -140,7 +142,8 @@ struct RuleStatusTests {
@Test("A spent time-limit budget still shows the blocked countdown")
func timeLimitBlockingDisplayLabel() {
let rule = BlockingRule(name: "Time Keeper", kind: .timeLimit, dailyLimitMinutes: 15)
let rule = BlockingRule(
name: "Time Keeper", configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 15)))
let now = date(2025, 1, 6, 11, 38)
let status = rule.status(at: now, calendar: utc, usage: RuleUsage(minutesUsed: 15))
#expect(status.isActive)

View File

@@ -41,7 +41,9 @@ struct RuleSnapshotTests {
let context = try makeInMemoryContext()
let list = AppList(name: "Distractions", selectionData: Data([9]), selectionCount: 1)
let rule = BlockingRule(
name: "Gate Keeper", kind: .openLimit, days: Weekday.weekends, maxOpens: 3)
name: "Gate Keeper",
configuration: .openLimit(OpenLimitConfig(maxOpens: 3)),
days: Weekday.weekends)
context.insert(list)
context.insert(rule)
rule.appList = list
@@ -135,7 +137,8 @@ struct RuleSchedulerTests {
private func limitRule(kind: RuleKind, name: String) throws -> BlockingRule {
let context = try makeInMemoryContext()
let list = AppList(name: "Apps", selectionData: Data([1]), selectionCount: 1)
let rule = BlockingRule(name: name, kind: kind, days: Weekday.everyDay)
let rule = BlockingRule(
name: name, configuration: .default(for: kind), days: Weekday.everyDay)
context.insert(list)
context.insert(rule)
rule.appList = list
@@ -148,7 +151,9 @@ struct RuleSchedulerTests {
) throws -> BlockingRule {
let context = try makeInMemoryContext()
let rule = BlockingRule(
name: name, kind: .schedule, days: days, startMinutes: start, endMinutes: end)
name: name,
configuration: .schedule(ScheduleConfig(startMinutes: start, endMinutes: end)),
days: days)
context.insert(rule)
if withApps {
let list = AppList(name: "Apps", selectionData: Data([7]), selectionCount: 1)
@@ -186,7 +191,7 @@ struct RuleSchedulerTests {
let (scheduler, monitor, _) = makeScheduler()
let context = try makeInMemoryContext()
let applessSchedule = BlockingRule(name: "Work Time")
let applessLimit = BlockingRule(name: "Empty", kind: .timeLimit)
let applessLimit = BlockingRule(name: "Empty", configuration: .timeLimit(TimeLimitConfig()))
context.insert(applessSchedule)
context.insert(applessLimit)

View File

@@ -79,13 +79,17 @@ struct UsageStatusTests {
let mondayMorning = date(2025, 1, 6, 10, 0)
private func timeLimitRule(limit: Int = 45) -> BlockingRule {
BlockingRule(name: "Time Keeper", kind: .timeLimit, days: Weekday.everyDay,
dailyLimitMinutes: limit)
BlockingRule(
name: "Time Keeper",
configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: limit)),
days: Weekday.everyDay)
}
private func openLimitRule(maxOpens: Int = 5) -> BlockingRule {
BlockingRule(name: "Gate Keeper", kind: .openLimit, days: Weekday.everyDay,
maxOpens: maxOpens)
BlockingRule(
name: "Gate Keeper",
configuration: .openLimit(OpenLimitConfig(maxOpens: maxOpens)),
days: Weekday.everyDay)
}
@Test("A time-limit rule with budget left is not active")
@@ -153,13 +157,17 @@ struct UsageEnforcementTests {
let ledger = MockUsageLedger()
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
let rule = BlockingRule(
name: "Time Keeper", kind: .timeLimit, days: Weekday.everyDay, dailyLimitMinutes: 45)
name: "Time Keeper",
configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 45)),
days: Weekday.everyDay)
ledger.usageByRule[rule.id] = RuleUsage(minutesUsed: 45)
enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc)
#expect(shields.shieldedRuleIDs == [rule.id])
#expect(shields.appliedModes[rule.id] == .block)
// Limit rules never engage the adult-content filter (Schedule-only).
#expect(shields.appliedAdultContentFlags[rule.id] == false)
}
@Test("A limit rule with budget left is not shielded")
@@ -168,7 +176,9 @@ struct UsageEnforcementTests {
let ledger = MockUsageLedger()
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
let rule = BlockingRule(
name: "Gate Keeper", kind: .openLimit, days: Weekday.everyDay, maxOpens: 5)
name: "Gate Keeper",
configuration: .openLimit(OpenLimitConfig(maxOpens: 5)),
days: Weekday.everyDay)
ledger.usageByRule[rule.id] = RuleUsage(opensUsed: 2)
enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc)
@@ -181,9 +191,13 @@ struct UsageEnforcementTests {
@Suite("Usage display strings")
struct UsageDisplayTests {
let timeRule = BlockingRule(
name: "Time Keeper", kind: .timeLimit, days: Weekday.everyDay, dailyLimitMinutes: 45)
name: "Time Keeper",
configuration: .timeLimit(TimeLimitConfig(dailyLimitMinutes: 45)),
days: Weekday.everyDay)
let openRule = BlockingRule(
name: "Gate Keeper", kind: .openLimit, days: Weekday.everyDay, maxOpens: 5)
name: "Gate Keeper",
configuration: .openLimit(OpenLimitConfig(maxOpens: 5)),
days: Weekday.everyDay)
@Test("Time-limit rows show minutes used and remaining")
func timeLimitStrings() {