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

@@ -131,6 +131,32 @@ final class RuleCreationUITests: XCTestCase {
XCTAssertTrue(row.label.contains("Allowed"), "Got: \(row.label)")
}
/// Block Adult Content is a Schedule-only option: the Time Limit editor must
/// not offer the toggle, and the rule's detail must not show the row.
func testTimeLimitOmitsAdultContent() throws {
let app = XCUIApplication.launchOpenAppLock()
app.buttons["newRuleButton"].waitToAppear().tap()
app.buttons["ruleKind-timeLimit"].waitToAppear().tap()
// Scroll to the bottom of the form, where Hard Mode lives. The adult
// toggle (which would sit beside it on a Schedule rule) is absent.
app.staticTexts["ruleEditorTitle"].waitToAppear()
app.swipeUp()
app.switches["hardModeToggle"].waitToAppear()
XCTAssertFalse(
app.switches["adultContentToggle"].exists,
"Time-limit rules must not offer Block Adult Content"
)
app.buttons["commitRuleButton"].waitToAppear().tap()
app.buttons["ruleCard-Time Keeper"].waitToAppear().tap()
app.element("detailRuleName").waitToAppear()
XCTAssertFalse(
app.element("detailRow-Adult websites").exists,
"Time-limit detail must not show the Adult websites row"
)
}
func testEditorSupportsNativeSwipeBack() throws {
let app = XCUIApplication.launchOpenAppLock()
app.buttons["newRuleButton"].waitToAppear().tap()