feat: clone Opal's rules feature with hard block enforcement
Rebuild the app around recurring screen-time blocking rules modeled on Opal's My Apps tab: - Onboarding with FamilyControls Screen Time authorization - Apps home with Blocked Apps tiles and live rule cards - New Rule sheet: Schedule/Time Limit/Open Limit types + preset gallery - Rule editors: time windows (incl. overnight), day picker, app selection via FamilyActivityPicker (Block/Allow Only), rename, Hold to Commit - Hard Mode: active hard rules cannot be edited, disabled, deleted, or unblocked until their window ends; soft rules pause until next window - Shield enforcement through per-rule ManagedSettingsStore + RuleEnforcer - 73 unit tests (Swift Testing) + 16 UI tests (XCUITest) with launch-arg harness for in-memory storage, mocked authorization, seeded scenarios - docs/RULES_FEATURE_SPEC.md: spec derived from the reference recording Known gap: background window transitions and time/open-limit thresholds need a DeviceActivityMonitor extension; shields currently sync while the app is running. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
31
SeveredUITests/OnboardingUITests.swift
Normal file
31
SeveredUITests/OnboardingUITests.swift
Normal file
@@ -0,0 +1,31 @@
|
||||
//
|
||||
// OnboardingUITests.swift
|
||||
// SeveredUITests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class OnboardingUITests: XCTestCase {
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testOnboardingWalksThroughPermissionToHome() throws {
|
||||
let app = XCUIApplication.launchSevered(onboardingCompleted: false)
|
||||
|
||||
// Welcome step.
|
||||
app.staticTexts["Severed"].waitToAppear()
|
||||
app.buttons["onboardingContinueButton"].waitToAppear().tap()
|
||||
|
||||
// Permission step: granting (mocked) lands on the Apps home screen.
|
||||
app.buttons["allowScreenTimeButton"].waitToAppear().tap()
|
||||
app.buttons["newRuleButton"].waitToAppear()
|
||||
XCTAssertTrue(app.staticTexts["Blocked Apps"].exists)
|
||||
}
|
||||
|
||||
func testCompletedOnboardingIsSkipped() throws {
|
||||
let app = XCUIApplication.launchSevered(onboardingCompleted: true)
|
||||
app.buttons["newRuleButton"].waitToAppear()
|
||||
XCTAssertFalse(app.buttons["onboardingContinueButton"].exists)
|
||||
}
|
||||
}
|
||||
99
SeveredUITests/RuleCreationUITests.swift
Normal file
99
SeveredUITests/RuleCreationUITests.swift
Normal file
@@ -0,0 +1,99 @@
|
||||
//
|
||||
// RuleCreationUITests.swift
|
||||
// SeveredUITests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class RuleCreationUITests: XCTestCase {
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testCreateScheduleRuleFromTypeCard() throws {
|
||||
let app = XCUIApplication.launchSevered()
|
||||
app.element("emptyRulesCard").waitToAppear()
|
||||
|
||||
app.buttons["newRuleButton"].tap()
|
||||
app.staticTexts["New Rule"].waitToAppear()
|
||||
app.buttons["ruleKind-schedule"].waitToAppear().tap()
|
||||
|
||||
// Editor opens with the schedule defaults.
|
||||
XCTAssertEqual(app.staticTexts["ruleEditorTitle"].waitToAppear().label, "In the Zone")
|
||||
XCTAssertTrue(app.staticTexts["During this time"].exists)
|
||||
|
||||
// Hold to commit saves the rule and returns home.
|
||||
app.buttons["holdToCommitButton"].waitToAppear().press(forDuration: 2.0)
|
||||
app.buttons["ruleCard-In the Zone"].waitToAppear()
|
||||
XCTAssertFalse(app.element("emptyRulesCard").exists)
|
||||
}
|
||||
|
||||
func testCreateRuleFromPreset() throws {
|
||||
let app = XCUIApplication.launchSevered()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
|
||||
app.buttons["preset-work-time"].waitToAppear().tap()
|
||||
XCTAssertEqual(app.staticTexts["ruleEditorTitle"].waitToAppear().label, "Work Time")
|
||||
|
||||
app.buttons["holdToCommitButton"].waitToAppear().press(forDuration: 2.0)
|
||||
app.buttons["ruleCard-Work Time"].waitToAppear()
|
||||
}
|
||||
|
||||
func testRenameRuleInEditor() throws {
|
||||
let app = XCUIApplication.launchSevered()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
app.buttons["ruleKind-schedule"].waitToAppear().tap()
|
||||
|
||||
app.buttons["renameButton"].waitToAppear().tap()
|
||||
let nameField = app.textFields.firstMatch.waitToAppear()
|
||||
nameField.tap()
|
||||
// Clear the prefilled name, then type the new one.
|
||||
let deletions = String(repeating: XCUIKeyboardKey.delete.rawValue, count: 24)
|
||||
nameField.typeText(deletions + "My Focus")
|
||||
app.buttons["OK"].tap()
|
||||
|
||||
XCTAssertEqual(app.staticTexts["ruleEditorTitle"].label, "My Focus")
|
||||
app.buttons["holdToCommitButton"].waitToAppear().press(forDuration: 2.0)
|
||||
app.buttons["ruleCard-My Focus"].waitToAppear()
|
||||
}
|
||||
|
||||
func testDayTogglesUpdateSummary() throws {
|
||||
let app = XCUIApplication.launchSevered()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
app.buttons["ruleKind-schedule"].waitToAppear().tap()
|
||||
|
||||
// Default is Weekdays; enabling Saturday and Sunday makes it Every day.
|
||||
XCTAssertTrue(app.staticTexts["Weekdays"].waitToAppear().exists)
|
||||
app.buttons["dayToggle-1"].tap()
|
||||
app.buttons["dayToggle-7"].tap()
|
||||
XCTAssertTrue(app.staticTexts["Every day"].waitToAppear().exists)
|
||||
}
|
||||
|
||||
func testCreateTimeLimitRule() throws {
|
||||
let app = XCUIApplication.launchSevered()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
app.buttons["ruleKind-timeLimit"].waitToAppear().tap()
|
||||
|
||||
XCTAssertEqual(app.staticTexts["ruleEditorTitle"].waitToAppear().label, "Time Keeper")
|
||||
XCTAssertTrue(app.staticTexts["When I use"].exists)
|
||||
XCTAssertEqual(app.staticTexts["dailyLimitStepperValue"].label, "45m")
|
||||
|
||||
app.steppers["dailyLimitStepper"].buttons["Increment"].tap()
|
||||
XCTAssertEqual(app.staticTexts["dailyLimitStepperValue"].label, "60m")
|
||||
|
||||
app.buttons["holdToCommitButton"].waitToAppear().press(forDuration: 2.0)
|
||||
app.buttons["ruleCard-Time Keeper"].waitToAppear()
|
||||
}
|
||||
|
||||
func testNewRuleSheetShowsTypesAndPresets() throws {
|
||||
let app = XCUIApplication.launchSevered()
|
||||
app.buttons["newRuleButton"].waitToAppear().tap()
|
||||
|
||||
app.buttons["ruleKind-schedule"].waitToAppear()
|
||||
XCTAssertTrue(app.buttons["ruleKind-timeLimit"].exists)
|
||||
XCTAssertTrue(app.buttons["ruleKind-openLimit"].exists)
|
||||
XCTAssertTrue(app.staticTexts["Get More Done"].exists)
|
||||
XCTAssertTrue(app.buttons["preset-work-time"].exists)
|
||||
XCTAssertTrue(app.buttons["preset-laser-focus"].exists)
|
||||
}
|
||||
}
|
||||
128
SeveredUITests/RuleManagementUITests.swift
Normal file
128
SeveredUITests/RuleManagementUITests.swift
Normal file
@@ -0,0 +1,128 @@
|
||||
//
|
||||
// RuleManagementUITests.swift
|
||||
// SeveredUITests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
/// Detail sheet, editing, disabling, deleting, and unblocking — seeded with
|
||||
/// one actively-blocking rule ("Work Time") and one upcoming rule ("Sleep").
|
||||
final class RuleManagementUITests: XCTestCase {
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testDetailShowsLiveStatusAndFacts() throws {
|
||||
let app = XCUIApplication.launchSevered(seedScenario: "standard")
|
||||
|
||||
app.buttons["ruleCard-Work Time"].waitToAppear().tap()
|
||||
|
||||
XCTAssertEqual(app.staticTexts["detailRuleName"].waitToAppear().label, "Work Time")
|
||||
XCTAssertTrue(app.staticTexts["detailStatusLabel"].label.contains("left"))
|
||||
app.element("detailRow-During this time").waitToAppear()
|
||||
XCTAssertTrue(app.element("detailRow-On these days").exists)
|
||||
XCTAssertTrue(app.element("detailRow-Unblocks allowed").exists)
|
||||
app.buttons["editRuleButton"].waitToAppear()
|
||||
|
||||
app.buttons["closeDetailButton"].tap()
|
||||
app.buttons["newRuleButton"].waitToAppear()
|
||||
}
|
||||
|
||||
func testEditRuleTogglesHardModeOn() throws {
|
||||
let app = XCUIApplication.launchSevered(seedScenario: "standard")
|
||||
|
||||
app.buttons["ruleCard-Sleep"].waitToAppear().tap()
|
||||
app.buttons["editRuleButton"].waitToAppear().tap()
|
||||
|
||||
app.switches["hardModeToggle"].waitToAppear().tap()
|
||||
app.buttons["doneButton"].tap()
|
||||
|
||||
// Back on the detail view, unblocks are no longer allowed.
|
||||
let row = app.element("detailRow-Unblocks allowed").waitToAppear()
|
||||
XCTAssertTrue(row.label.contains("No"), "Expected 'Unblocks allowed: No', got: \(row.label)")
|
||||
}
|
||||
|
||||
func testDisableRule() throws {
|
||||
let app = XCUIApplication.launchSevered(seedScenario: "standard")
|
||||
|
||||
app.buttons["ruleCard-Sleep"].waitToAppear().tap()
|
||||
app.buttons["editRuleButton"].waitToAppear().tap()
|
||||
app.buttons["toggleEnabledButton"].waitToAppear().tap()
|
||||
|
||||
// The detail caption now reports the rule as disabled.
|
||||
let status = app.staticTexts["detailStatusLabel"].waitToAppear()
|
||||
XCTAssertTrue(status.label.contains("Disabled"), "Got: \(status.label)")
|
||||
|
||||
app.buttons["closeDetailButton"].tap()
|
||||
let cardStatus = app.staticTexts["ruleStatus-Sleep"].waitToAppear()
|
||||
XCTAssertEqual(cardStatus.label, "Disabled")
|
||||
}
|
||||
|
||||
func testDeleteRuleRemovesCard() throws {
|
||||
let app = XCUIApplication.launchSevered(seedScenario: "standard")
|
||||
|
||||
app.buttons["ruleCard-Sleep"].waitToAppear().tap()
|
||||
app.buttons["editRuleButton"].waitToAppear().tap()
|
||||
app.buttons["deleteRuleButton"].waitToAppear().tap()
|
||||
|
||||
app.buttons["newRuleButton"].waitToAppear()
|
||||
XCTAssertFalse(
|
||||
app.buttons["ruleCard-Sleep"].waitForExistence(timeout: 2),
|
||||
"Deleted rule's card should disappear"
|
||||
)
|
||||
XCTAssertTrue(app.buttons["ruleCard-Work Time"].exists)
|
||||
}
|
||||
|
||||
func testUnblockActiveSoftRule() throws {
|
||||
let app = XCUIApplication.launchSevered(seedScenario: "standard")
|
||||
|
||||
// The active rule surfaces in Blocked Apps; unblocking pauses it.
|
||||
app.buttons["blockedTile-Work Time"].waitToAppear().tap()
|
||||
app.sheets.buttons["Unblock"].waitToAppear().tap()
|
||||
|
||||
app.staticTexts["nothingBlockedLabel"].waitToAppear()
|
||||
XCTAssertEqual(app.staticTexts["ruleStatus-Work Time"].waitToAppear().label, "Paused")
|
||||
}
|
||||
}
|
||||
|
||||
/// Hard block behavior — seeded with an actively-blocking Hard Mode rule.
|
||||
final class HardModeUITests: XCTestCase {
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testHardLockedRuleCannotBeEdited() throws {
|
||||
let app = XCUIApplication.launchSevered(seedScenario: "hard-mode-active")
|
||||
|
||||
app.buttons["ruleCard-Locked In"].waitToAppear().tap()
|
||||
|
||||
// The lock notice replaces Edit Rule entirely.
|
||||
app.element("hardModeLockedNotice").waitToAppear()
|
||||
XCTAssertFalse(app.buttons["editRuleButton"].exists)
|
||||
XCTAssertTrue(app.element("detailRow-Unblocks allowed").label.contains("No"))
|
||||
}
|
||||
|
||||
func testHardLockedRuleCannotBeUnblocked() throws {
|
||||
let app = XCUIApplication.launchSevered(seedScenario: "hard-mode-active")
|
||||
|
||||
app.buttons["blockedTile-Locked In"].waitToAppear().tap()
|
||||
|
||||
// No unblock dialog — just the refusal alert.
|
||||
let alert = app.alerts["Hard Mode is on"].waitToAppear()
|
||||
XCTAssertTrue(alert.staticTexts["This block can't be lifted until it ends."].exists)
|
||||
alert.buttons["OK"].tap()
|
||||
|
||||
// Still blocked.
|
||||
XCTAssertTrue(app.buttons["blockedTile-Locked In"].exists)
|
||||
XCTAssertFalse(app.staticTexts["nothingBlockedLabel"].exists)
|
||||
}
|
||||
|
||||
func testSoftRuleUnblockOfferedButHardRuleRefused() throws {
|
||||
let app = XCUIApplication.launchSevered(seedScenario: "standard")
|
||||
|
||||
app.buttons["blockedTile-Work Time"].waitToAppear().tap()
|
||||
// Soft rule: the confirmation dialog appears instead of the refusal alert.
|
||||
app.sheets.buttons["Unblock"].waitToAppear()
|
||||
XCTAssertFalse(app.alerts["Hard Mode is on"].exists)
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
//
|
||||
// SeveredUITests.swift
|
||||
// SeveredUITests
|
||||
//
|
||||
// Created by Brendan Chen on 2025.08.09.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class SeveredUITests: XCTestCase {
|
||||
|
||||
override func setUpWithError() throws {
|
||||
// Put setup code here. This method is called before the invocation of each test method in the class.
|
||||
|
||||
// In UI tests it is usually best to stop immediately when a failure occurs.
|
||||
continueAfterFailure = false
|
||||
|
||||
// In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this.
|
||||
}
|
||||
|
||||
override func tearDownWithError() throws {
|
||||
// Put teardown code here. This method is called after the invocation of each test method in the class.
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testExample() throws {
|
||||
// UI tests must launch the application that they test.
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// Use XCTAssert and related functions to verify your tests produce the correct results.
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLaunchPerformance() throws {
|
||||
// This measures how long it takes to launch your application.
|
||||
measure(metrics: [XCTApplicationLaunchMetric()]) {
|
||||
XCUIApplication().launch()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
//
|
||||
// SeveredUITestsLaunchTests.swift
|
||||
// SeveredUITests
|
||||
//
|
||||
// Created by Brendan Chen on 2025.08.09.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
final class SeveredUITestsLaunchTests: XCTestCase {
|
||||
|
||||
override class var runsForEachTargetApplicationUIConfiguration: Bool {
|
||||
true
|
||||
}
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testLaunch() throws {
|
||||
let app = XCUIApplication()
|
||||
app.launch()
|
||||
|
||||
// Insert steps here to perform after app launch but before taking a screenshot,
|
||||
// such as logging into a test account or navigating somewhere in the app
|
||||
|
||||
let attachment = XCTAttachment(screenshot: app.screenshot())
|
||||
attachment.name = "Launch Screen"
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
}
|
||||
}
|
||||
48
SeveredUITests/UITestSupport.swift
Normal file
48
SeveredUITests/UITestSupport.swift
Normal file
@@ -0,0 +1,48 @@
|
||||
//
|
||||
// UITestSupport.swift
|
||||
// SeveredUITests
|
||||
//
|
||||
|
||||
import XCTest
|
||||
|
||||
extension XCUIApplication {
|
||||
/// Launches the app in UI-testing mode: in-memory storage, mocked Screen
|
||||
/// Time authorization, and no shield side effects.
|
||||
static func launchSevered(
|
||||
onboardingCompleted: Bool = true,
|
||||
seedScenario: String? = nil
|
||||
) -> XCUIApplication {
|
||||
let app = XCUIApplication()
|
||||
var arguments = ["-ui-testing"]
|
||||
arguments.append(onboardingCompleted ? "-onboarding-completed" : "-onboarding-required")
|
||||
if let seedScenario {
|
||||
arguments.append("-seed-scenario=\(seedScenario)")
|
||||
}
|
||||
app.launchArguments = arguments
|
||||
app.launch()
|
||||
return app
|
||||
}
|
||||
}
|
||||
|
||||
extension XCUIApplication {
|
||||
/// Finds an element by accessibility identifier regardless of how SwiftUI
|
||||
/// exposes it (Other, StaticText, Button, …).
|
||||
func element(_ identifier: String) -> XCUIElement {
|
||||
descendants(matching: .any).matching(identifier: identifier).firstMatch
|
||||
}
|
||||
}
|
||||
|
||||
extension XCUIElement {
|
||||
/// Asserts the element appears within the timeout, then returns it.
|
||||
@discardableResult
|
||||
func waitToAppear(
|
||||
timeout: TimeInterval = 5, file: StaticString = #filePath, line: UInt = #line
|
||||
) -> XCUIElement {
|
||||
XCTAssertTrue(
|
||||
waitForExistence(timeout: timeout),
|
||||
"Expected \(self) to exist within \(timeout)s",
|
||||
file: file, line: line
|
||||
)
|
||||
return self
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user