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:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user