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).
This commit is contained in:
2026-06-13 12:49:16 -04:00
parent 52802a9984
commit fc0f518608
20 changed files with 612 additions and 258 deletions

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)
}
}