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