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

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