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

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