feat: track limit budgets in a usage ledger and surface them in a Usage section
- RuleUsage + UsageLedger: per-rule, per-day minutes/opens in app-group defaults (monotonic minutes, incrementing opens, midnight rollover by day-keying); MockUsageLedger for tests and seeded UI scenarios - usage-aware status: a limit rule whose daily budget is spent on an enabled day is active (blocking) until next midnight; Hard Mode gating and app-list locking honor it; soft unblock pauses until midnight - RuleEnforcer shields spent limit rules (always Block mode) while the app runs - new Usage section under Blocked Apps: '18m of 45m used today · 27m left', '2 of 5 opens today · 3 opens left', 'Blocked until tomorrow' - new 'limits' seed scenario + UI tests
This commit is contained in:
@@ -8,46 +8,55 @@ import Foundation
|
|||||||
/// Gates every mutation of a rule. This is where Hard Mode is enforced:
|
/// Gates every mutation of a rule. This is where Hard Mode is enforced:
|
||||||
/// while a hard-mode rule is actively blocking, nothing about it can be
|
/// while a hard-mode rule is actively blocking, nothing about it can be
|
||||||
/// weakened until the window ends.
|
/// weakened until the window ends.
|
||||||
|
///
|
||||||
|
/// Limit rules block on spent usage rather than the clock, so their gates
|
||||||
|
/// take the day's `RuleUsage`; passing nil treats them as not blocking.
|
||||||
enum RulePolicy {
|
enum RulePolicy {
|
||||||
/// True while the rule is actively blocking with Hard Mode on.
|
/// True while the rule is actively blocking with Hard Mode on.
|
||||||
static func isHardLocked(
|
static func isHardLocked(
|
||||||
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
|
_ rule: BlockingRule, usage: RuleUsage? = nil,
|
||||||
|
at now: Date = .now, calendar: Calendar = .current
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
rule.hardMode && rule.status(at: now, calendar: calendar).isActive
|
rule.hardMode && rule.status(at: now, calendar: calendar, usage: usage).isActive
|
||||||
}
|
}
|
||||||
|
|
||||||
static func canEdit(
|
static func canEdit(
|
||||||
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
|
_ rule: BlockingRule, usage: RuleUsage? = nil,
|
||||||
|
at now: Date = .now, calendar: Calendar = .current
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
!isHardLocked(rule, at: now, calendar: calendar)
|
!isHardLocked(rule, usage: usage, at: now, calendar: calendar)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func canDisable(
|
static func canDisable(
|
||||||
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
|
_ rule: BlockingRule, usage: RuleUsage? = nil,
|
||||||
|
at now: Date = .now, calendar: Calendar = .current
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
!isHardLocked(rule, at: now, calendar: calendar)
|
!isHardLocked(rule, usage: usage, at: now, calendar: calendar)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func canDelete(
|
static func canDelete(
|
||||||
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
|
_ rule: BlockingRule, usage: RuleUsage? = nil,
|
||||||
|
at now: Date = .now, calendar: Calendar = .current
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
!isHardLocked(rule, at: now, calendar: calendar)
|
!isHardLocked(rule, usage: usage, at: now, calendar: calendar)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the user may lift the current block early ("Unblock").
|
/// Whether the user may lift the current block early ("Unblock").
|
||||||
/// Requires an active window and Hard Mode off.
|
/// Requires an active block and Hard Mode off.
|
||||||
static func canUnblock(
|
static func canUnblock(
|
||||||
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
|
_ rule: BlockingRule, usage: RuleUsage? = nil,
|
||||||
|
at now: Date = .now, calendar: Calendar = .current
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
rule.status(at: now, calendar: calendar).isActive && !rule.hardMode
|
rule.status(at: now, calendar: calendar, usage: usage).isActive && !rule.hardMode
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hard Mode can always be turned on, but never off while the rule is
|
/// Hard Mode can always be turned on, but never off while the rule is
|
||||||
/// actively blocking — that is the whole point of a hard block.
|
/// actively blocking — that is the whole point of a hard block.
|
||||||
static func canTurnOffHardMode(
|
static func canTurnOffHardMode(
|
||||||
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
|
_ rule: BlockingRule, usage: RuleUsage? = nil,
|
||||||
|
at now: Date = .now, calendar: Calendar = .current
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
!isHardLocked(rule, at: now, calendar: calendar)
|
!isHardLocked(rule, usage: usage, at: now, calendar: calendar)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// App lists feed active shields, so while any hard-mode rule is actively
|
/// App lists feed active shields, so while any hard-mode rule is actively
|
||||||
@@ -55,21 +64,32 @@ enum RulePolicy {
|
|||||||
/// back door out of the hard block. Creating new lists and picking lists
|
/// back door out of the hard block. Creating new lists and picking lists
|
||||||
/// for other rules stay allowed; they cannot weaken an active block.
|
/// for other rules stay allowed; they cannot weaken an active block.
|
||||||
static func canEditAppLists(
|
static func canEditAppLists(
|
||||||
rules: [BlockingRule], at now: Date = .now, calendar: Calendar = .current
|
rules: [BlockingRule], usageFor: (BlockingRule) -> RuleUsage? = { _ in nil },
|
||||||
|
at now: Date = .now, calendar: Calendar = .current
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
!rules.contains { isHardLocked($0, at: now, calendar: calendar) }
|
!rules.contains {
|
||||||
|
isHardLocked($0, usage: usageFor($0), at: now, calendar: calendar)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pauses the rule's current window. Returns false (and changes nothing)
|
/// Pauses the rule's current block. Returns false (and changes nothing)
|
||||||
/// when unblocking is not allowed.
|
/// when unblocking is not allowed. Schedule rules re-arm at their next
|
||||||
|
/// window; limit rules re-arm at midnight with the next day's budget.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
static func unblock(
|
static func unblock(
|
||||||
_ rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
|
_ rule: BlockingRule, usage: RuleUsage? = nil,
|
||||||
|
at now: Date = .now, calendar: Calendar = .current
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
guard canUnblock(rule, at: now, calendar: calendar),
|
guard canUnblock(rule, usage: usage, at: now, calendar: calendar) else { return false }
|
||||||
let window = rule.schedule.activeWindow(containing: now, calendar: calendar)
|
switch rule.kind {
|
||||||
|
case .schedule:
|
||||||
|
guard let window = rule.schedule.activeWindow(containing: now, calendar: calendar)
|
||||||
else { return false }
|
else { return false }
|
||||||
rule.pausedUntil = window.end
|
rule.pausedUntil = window.end
|
||||||
|
case .timeLimit, .openLimit:
|
||||||
|
guard let midnight = calendar.nextMidnight(after: now) else { return false }
|
||||||
|
rule.pausedUntil = midnight
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,14 +45,25 @@ enum RuleStatus: Equatable, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension BlockingRule {
|
extension BlockingRule {
|
||||||
/// Live status of this rule. Schedule rules derive it from their time window;
|
/// Live status of this rule. Schedule rules derive it from their time
|
||||||
/// time/open-limit rules report upcoming based on their enabled days only
|
/// window. Time/open-limit rules derive it from the day's usage: once the
|
||||||
/// (their blocking is triggered by usage, not the clock).
|
/// budget is spent on an enabled day they are active (blocking) until the
|
||||||
func status(at now: Date = .now, calendar: Calendar = .current) -> RuleStatus {
|
/// next midnight; without usage data they report upcoming.
|
||||||
|
func status(
|
||||||
|
at now: Date = .now, calendar: Calendar = .current, usage: RuleUsage? = nil
|
||||||
|
) -> RuleStatus {
|
||||||
guard isEnabled else { return .disabled }
|
guard isEnabled else { return .disabled }
|
||||||
guard !days.isEmpty else { return .dormant }
|
guard !days.isEmpty else { return .dormant }
|
||||||
|
|
||||||
guard kind == .schedule else {
|
guard kind == .schedule else {
|
||||||
|
if let usage, isScheduledToday(at: now, calendar: calendar),
|
||||||
|
limitReached(given: usage),
|
||||||
|
let midnight = calendar.nextMidnight(after: now) {
|
||||||
|
if let pausedUntil, pausedUntil > now {
|
||||||
|
return .paused(until: min(pausedUntil, midnight))
|
||||||
|
}
|
||||||
|
return .active(until: midnight)
|
||||||
|
}
|
||||||
guard let next = schedule.nextStart(after: now, calendar: calendar) else {
|
guard let next = schedule.nextStart(after: now, calendar: calendar) else {
|
||||||
return .dormant
|
return .dormant
|
||||||
}
|
}
|
||||||
@@ -70,4 +81,30 @@ extension BlockingRule {
|
|||||||
}
|
}
|
||||||
return .dormant
|
return .dormant
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the rule's enabled days include the day containing `now`.
|
||||||
|
func isScheduledToday(at now: Date, calendar: Calendar = .current) -> Bool {
|
||||||
|
guard let weekday = Weekday(rawValue: calendar.component(.weekday, from: now)) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return days.contains(weekday)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the given usage exhausts this rule's daily budget.
|
||||||
|
/// Always false for schedule rules — they block by the clock.
|
||||||
|
func limitReached(given usage: RuleUsage) -> Bool {
|
||||||
|
switch kind {
|
||||||
|
case .schedule: false
|
||||||
|
case .timeLimit: usage.minutesUsed >= dailyLimitMinutes
|
||||||
|
case .openLimit: usage.opensUsed >= maxOpens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Calendar {
|
||||||
|
/// The first instant of the day after the one containing `date` — the
|
||||||
|
/// "Tomorrow" reset point for spent limit budgets.
|
||||||
|
func nextMidnight(after date: Date) -> Date? {
|
||||||
|
self.date(byAdding: .day, value: 1, to: startOfDay(for: date))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
40
OpenAppLock/Logic/UsageDisplay.swift
Normal file
40
OpenAppLock/Logic/UsageDisplay.swift
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
//
|
||||||
|
// UsageDisplay.swift
|
||||||
|
// OpenAppLock
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Strings for the home screen's Usage section. Used values clamp to the
|
||||||
|
/// budget so overshoot (thresholds can fire late) never reads "50m of 45m".
|
||||||
|
enum UsageDisplay {
|
||||||
|
/// "18m of 45m used today" / "2 of 5 opens today".
|
||||||
|
static func subtitle(for rule: BlockingRule, usage: RuleUsage) -> String {
|
||||||
|
switch rule.kind {
|
||||||
|
case .schedule:
|
||||||
|
""
|
||||||
|
case .timeLimit:
|
||||||
|
"\(min(usage.minutesUsed, rule.dailyLimitMinutes))m of "
|
||||||
|
+ "\(rule.dailyLimitMinutes)m used today"
|
||||||
|
case .openLimit:
|
||||||
|
"\(min(usage.opensUsed, rule.maxOpens)) of \(rule.maxOpens) opens today"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "27m left" / "3 opens left", or the blocked/unblocked state once the
|
||||||
|
/// budget is spent.
|
||||||
|
static func remainingLabel(for rule: BlockingRule, usage: RuleUsage, isPaused: Bool) -> String {
|
||||||
|
guard !rule.limitReached(given: usage) else {
|
||||||
|
return isPaused ? "Unblocked until tomorrow" : "Blocked until tomorrow"
|
||||||
|
}
|
||||||
|
switch rule.kind {
|
||||||
|
case .schedule:
|
||||||
|
return ""
|
||||||
|
case .timeLimit:
|
||||||
|
return "\(rule.dailyLimitMinutes - usage.minutesUsed)m left"
|
||||||
|
case .openLimit:
|
||||||
|
let remaining = rule.maxOpens - usage.opensUsed
|
||||||
|
return remaining == 1 ? "1 open left" : "\(remaining) opens left"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,8 +31,12 @@ struct OpenAppLockApp: App {
|
|||||||
fatalError("Could not create ModelContainer: \(error)")
|
fatalError("Could not create ModelContainer: \(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let usageLedger: UsageReading = config.isUITesting ? MockUsageLedger() : UsageLedger()
|
||||||
|
|
||||||
if let scenario = config.seedScenario {
|
if let scenario = config.seedScenario {
|
||||||
SampleRules.seed(scenario, into: container.mainContext)
|
SampleRules.seed(
|
||||||
|
scenario, into: container.mainContext, usage: usageLedger as? MockUsageLedger
|
||||||
|
)
|
||||||
}
|
}
|
||||||
AppListMigration.run(in: container.mainContext)
|
AppListMigration.run(in: container.mainContext)
|
||||||
|
|
||||||
@@ -46,7 +50,7 @@ struct OpenAppLockApp: App {
|
|||||||
|
|
||||||
let shields: ShieldApplying =
|
let shields: ShieldApplying =
|
||||||
config.isUITesting ? MockShieldController() : ManagedSettingsShieldController()
|
config.isUITesting ? MockShieldController() : ManagedSettingsShieldController()
|
||||||
_enforcer = State(initialValue: RuleEnforcer(shields: shields))
|
_enforcer = State(initialValue: RuleEnforcer(shields: shields, usage: usageLedger))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
|
|||||||
19
OpenAppLock/Services/AppGroup.swift
Normal file
19
OpenAppLock/Services/AppGroup.swift
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
//
|
||||||
|
// AppGroup.swift
|
||||||
|
// OpenAppLock
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The app group shared by the app and its Screen Time extensions. Usage
|
||||||
|
/// tracking and rule snapshots live in its UserDefaults suite so the
|
||||||
|
/// DeviceActivity monitor and shield extensions can read and write them.
|
||||||
|
enum AppGroup {
|
||||||
|
static let identifier = "group.dev.bchen.OpenAppLock"
|
||||||
|
|
||||||
|
/// Shared defaults; falls back to standard defaults when the group
|
||||||
|
/// container is unavailable (e.g. entitlement not provisioned yet).
|
||||||
|
static var defaults: UserDefaults {
|
||||||
|
UserDefaults(suiteName: identifier) ?? .standard
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,10 @@ struct LaunchConfiguration: Equatable {
|
|||||||
case standard
|
case standard
|
||||||
/// An actively blocking Hard Mode rule ("Locked In") plus an upcoming rule.
|
/// An actively blocking Hard Mode rule ("Locked In") plus an upcoming rule.
|
||||||
case hardModeActive = "hard-mode-active"
|
case hardModeActive = "hard-mode-active"
|
||||||
|
/// Limit rules with seeded usage: "Time Keeper" (18m of 45m),
|
||||||
|
/// "Gate Keeper" (2 of 5 opens), and "Doom Scroll" (budget spent →
|
||||||
|
/// blocked until tomorrow).
|
||||||
|
case limits
|
||||||
}
|
}
|
||||||
|
|
||||||
var isUITesting = false
|
var isUITesting = false
|
||||||
|
|||||||
@@ -7,18 +7,32 @@ import Foundation
|
|||||||
import Observation
|
import Observation
|
||||||
|
|
||||||
/// Turns the current set of rules into shield state: schedule rules with an
|
/// Turns the current set of rules into shield state: schedule rules with an
|
||||||
/// active, un-paused window are shielded; everything else is cleared.
|
/// active, un-paused window are shielded, and limit rules whose daily budget
|
||||||
|
/// is spent (per the usage ledger) are shielded until midnight; everything
|
||||||
|
/// else is cleared.
|
||||||
///
|
///
|
||||||
/// Time-limit and open-limit rules need a DeviceActivity monitor extension to
|
/// Background transitions (and usage tracking itself) belong to the
|
||||||
/// react to usage thresholds in the background; they are persisted and shown
|
/// DeviceActivity monitor extension; this keeps shields correct while the
|
||||||
/// in the UI but not yet enforced here.
|
/// app runs.
|
||||||
@Observable
|
@Observable
|
||||||
final class RuleEnforcer {
|
final class RuleEnforcer {
|
||||||
private(set) var blockingRuleIDs: Set<UUID> = []
|
private(set) var blockingRuleIDs: Set<UUID> = []
|
||||||
private let shields: ShieldApplying
|
private let shields: ShieldApplying
|
||||||
|
/// Day-usage source consulted for limit rules; also exposed to views for
|
||||||
|
/// the Usage section.
|
||||||
|
let usageReader: UsageReading
|
||||||
|
|
||||||
init(shields: ShieldApplying) {
|
init(shields: ShieldApplying, usage: UsageReading = UsageLedger()) {
|
||||||
self.shields = shields
|
self.shields = shields
|
||||||
|
self.usageReader = usage
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The day's usage for a rule (nil for schedule rules, which don't track).
|
||||||
|
func usage(
|
||||||
|
for rule: BlockingRule, at now: Date = .now, calendar: Calendar = .current
|
||||||
|
) -> RuleUsage? {
|
||||||
|
guard rule.kind != .schedule else { return nil }
|
||||||
|
return usageReader.usage(for: rule.id, onDayContaining: now, calendar: calendar)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recomputes shields from scratch. Call on launch, on any rule change,
|
/// Recomputes shields from scratch. Call on launch, on any rule change,
|
||||||
@@ -29,14 +43,14 @@ final class RuleEnforcer {
|
|||||||
if let pausedUntil = rule.pausedUntil, pausedUntil <= now {
|
if let pausedUntil = rule.pausedUntil, pausedUntil <= now {
|
||||||
rule.pausedUntil = nil
|
rule.pausedUntil = nil
|
||||||
}
|
}
|
||||||
guard rule.kind == .schedule,
|
let usage = usage(for: rule, at: now, calendar: calendar)
|
||||||
rule.status(at: now, calendar: calendar).isActive
|
guard rule.status(at: now, calendar: calendar, usage: usage).isActive else { continue }
|
||||||
else { continue }
|
|
||||||
active.insert(rule.id)
|
active.insert(rule.id)
|
||||||
shields.applyShield(
|
shields.applyShield(
|
||||||
ruleID: rule.id,
|
ruleID: rule.id,
|
||||||
selectionData: rule.appList?.selectionData,
|
selectionData: rule.appList?.selectionData,
|
||||||
mode: rule.selectionMode,
|
// Allow Only is a schedule-rule concept; limit blocks always Block.
|
||||||
|
mode: rule.kind == .schedule ? rule.selectionMode : .block,
|
||||||
blockAdultContent: rule.blockAdultContent
|
blockAdultContent: rule.blockAdultContent
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ enum SampleRules {
|
|||||||
static func seed(
|
static func seed(
|
||||||
_ scenario: LaunchConfiguration.SeedScenario,
|
_ scenario: LaunchConfiguration.SeedScenario,
|
||||||
into context: ModelContext,
|
into context: ModelContext,
|
||||||
|
usage: MockUsageLedger? = nil,
|
||||||
now: Date = .now,
|
now: Date = .now,
|
||||||
calendar: Calendar = .current
|
calendar: Calendar = .current
|
||||||
) {
|
) {
|
||||||
@@ -20,18 +21,31 @@ enum SampleRules {
|
|||||||
let distractions = AppList(name: "Distractions", selectionCount: 3)
|
let distractions = AppList(name: "Distractions", selectionCount: 3)
|
||||||
context.insert(distractions)
|
context.insert(distractions)
|
||||||
|
|
||||||
let rules =
|
let rules: [BlockingRule]
|
||||||
switch scenario {
|
switch scenario {
|
||||||
case .standard:
|
case .standard:
|
||||||
[
|
rules = [
|
||||||
activeRule(named: "Work Time", hardMode: false, now: now, calendar: calendar),
|
activeRule(named: "Work Time", hardMode: false, now: now, calendar: calendar),
|
||||||
upcomingRule(named: "Sleep", now: now, calendar: calendar),
|
upcomingRule(named: "Sleep", now: now, calendar: calendar),
|
||||||
]
|
]
|
||||||
case .hardModeActive:
|
case .hardModeActive:
|
||||||
[
|
rules = [
|
||||||
activeRule(named: "Locked In", hardMode: true, now: now, calendar: calendar),
|
activeRule(named: "Locked In", hardMode: true, now: now, calendar: calendar),
|
||||||
upcomingRule(named: "Sleep", now: now, calendar: calendar),
|
upcomingRule(named: "Sleep", now: now, calendar: calendar),
|
||||||
]
|
]
|
||||||
|
case .limits:
|
||||||
|
let timeKeeper = BlockingRule(
|
||||||
|
name: "Time Keeper", kind: .timeLimit, days: Weekday.everyDay,
|
||||||
|
dailyLimitMinutes: 45)
|
||||||
|
let gateKeeper = BlockingRule(
|
||||||
|
name: "Gate Keeper", kind: .openLimit, days: Weekday.everyDay, maxOpens: 5)
|
||||||
|
let doomScroll = BlockingRule(
|
||||||
|
name: "Doom Scroll", kind: .timeLimit, days: Weekday.everyDay,
|
||||||
|
dailyLimitMinutes: 30)
|
||||||
|
usage?.usageByRule[timeKeeper.id] = RuleUsage(minutesUsed: 18)
|
||||||
|
usage?.usageByRule[gateKeeper.id] = RuleUsage(opensUsed: 2)
|
||||||
|
usage?.usageByRule[doomScroll.id] = RuleUsage(minutesUsed: 30)
|
||||||
|
rules = [timeKeeper, gateKeeper, doomScroll]
|
||||||
}
|
}
|
||||||
// Relationships are wired only after both sides are managed
|
// Relationships are wired only after both sides are managed
|
||||||
// (see BlockingRule.appList).
|
// (see BlockingRule.appList).
|
||||||
|
|||||||
93
OpenAppLock/Services/UsageLedger.swift
Normal file
93
OpenAppLock/Services/UsageLedger.swift
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
//
|
||||||
|
// UsageLedger.swift
|
||||||
|
// OpenAppLock
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// What a limit rule has consumed on a given day. Written by the
|
||||||
|
/// DeviceActivity monitor (minutes) and shield-action extension (opens);
|
||||||
|
/// read by the app for display and enforcement.
|
||||||
|
struct RuleUsage: Codable, Equatable {
|
||||||
|
var minutesUsed = 0
|
||||||
|
var opensUsed = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read access to per-rule, per-day usage.
|
||||||
|
protocol UsageReading: AnyObject {
|
||||||
|
func usage(for ruleID: UUID, onDayContaining date: Date, calendar: Calendar) -> RuleUsage
|
||||||
|
}
|
||||||
|
|
||||||
|
extension UsageReading {
|
||||||
|
func usage(for ruleID: UUID, onDayContaining date: Date) -> RuleUsage {
|
||||||
|
usage(for: ruleID, onDayContaining: date, calendar: .current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Usage bookkeeping in the shared app-group defaults, keyed by calendar day
|
||||||
|
/// and rule. Old days are simply ignored; midnight needs no reset step.
|
||||||
|
final class UsageLedger: UsageReading {
|
||||||
|
private let defaults: UserDefaults
|
||||||
|
|
||||||
|
init(defaults: UserDefaults = AppGroup.defaults) {
|
||||||
|
self.defaults = defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "2026-06-12" — calendar-date key so budgets roll over at midnight.
|
||||||
|
static func dayKey(for date: Date, calendar: Calendar = .current) -> String {
|
||||||
|
let parts = calendar.dateComponents([.year, .month, .day], from: date)
|
||||||
|
return String(format: "%04d-%02d-%02d", parts.year ?? 0, parts.month ?? 0, parts.day ?? 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func usage(
|
||||||
|
for ruleID: UUID, onDayContaining date: Date, calendar: Calendar = .current
|
||||||
|
) -> RuleUsage {
|
||||||
|
guard let data = defaults.data(forKey: key(ruleID, date, calendar)),
|
||||||
|
let usage = try? JSONDecoder().decode(RuleUsage.self, from: data)
|
||||||
|
else { return RuleUsage() }
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
|
||||||
|
func setUsage(
|
||||||
|
_ usage: RuleUsage, for ruleID: UUID, onDayContaining date: Date,
|
||||||
|
calendar: Calendar = .current
|
||||||
|
) {
|
||||||
|
guard let data = try? JSONEncoder().encode(usage) else { return }
|
||||||
|
defaults.set(data, forKey: key(ruleID, date, calendar))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Threshold events report cumulative totals, so minutes only move up.
|
||||||
|
func recordMinutesUsed(
|
||||||
|
_ minutes: Int, for ruleID: UUID, onDayContaining date: Date,
|
||||||
|
calendar: Calendar = .current
|
||||||
|
) {
|
||||||
|
var usage = self.usage(for: ruleID, onDayContaining: date, calendar: calendar)
|
||||||
|
usage.minutesUsed = max(usage.minutesUsed, minutes)
|
||||||
|
setUsage(usage, for: ruleID, onDayContaining: date, calendar: calendar)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func recordOpen(
|
||||||
|
for ruleID: UUID, onDayContaining date: Date, calendar: Calendar = .current
|
||||||
|
) -> RuleUsage {
|
||||||
|
var usage = self.usage(for: ruleID, onDayContaining: date, calendar: calendar)
|
||||||
|
usage.opensUsed += 1
|
||||||
|
setUsage(usage, for: ruleID, onDayContaining: date, calendar: calendar)
|
||||||
|
return usage
|
||||||
|
}
|
||||||
|
|
||||||
|
private func key(_ ruleID: UUID, _ date: Date, _ calendar: Calendar) -> String {
|
||||||
|
"usage/\(Self.dayKey(for: date, calendar: calendar))/\(ruleID.uuidString)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seedable in-memory usage for tests and UI-test scenarios.
|
||||||
|
final class MockUsageLedger: UsageReading {
|
||||||
|
var usageByRule: [UUID: RuleUsage] = [:]
|
||||||
|
|
||||||
|
func usage(
|
||||||
|
for ruleID: UUID, onDayContaining date: Date, calendar: Calendar
|
||||||
|
) -> RuleUsage {
|
||||||
|
usageByRule[ruleID] ?? RuleUsage()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ struct AppListPickerSheet: View {
|
|||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(RuleEnforcer.self) private var enforcer
|
||||||
@Query(sort: \AppList.createdAt) private var lists: [AppList]
|
@Query(sort: \AppList.createdAt) private var lists: [AppList]
|
||||||
@Query private var rules: [BlockingRule]
|
@Query private var rules: [BlockingRule]
|
||||||
|
|
||||||
@@ -22,10 +23,11 @@ struct AppListPickerSheet: View {
|
|||||||
@State private var creatingList = false
|
@State private var creatingList = false
|
||||||
@State private var deletionBlocked = false
|
@State private var deletionBlocked = false
|
||||||
|
|
||||||
/// While any hard-mode rule is actively blocking, lists are read-only —
|
/// While any hard-mode rule is actively blocking (by the clock or a spent
|
||||||
/// editing one would be a back door out of the hard block.
|
/// limit budget), lists are read-only — editing one would be a back door
|
||||||
|
/// out of the hard block.
|
||||||
private var listsLocked: Bool {
|
private var listsLocked: Bool {
|
||||||
!RulePolicy.canEditAppLists(rules: rules)
|
!RulePolicy.canEditAppLists(rules: rules, usageFor: { enforcer.usage(for: $0) })
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ struct AppsHomeView: View {
|
|||||||
) {
|
) {
|
||||||
Button("Unblock", role: .destructive) {
|
Button("Unblock", role: .destructive) {
|
||||||
if let rule = unblockCandidate {
|
if let rule = unblockCandidate {
|
||||||
RulePolicy.unblock(rule)
|
RulePolicy.unblock(rule, usage: enforcer.usage(for: rule))
|
||||||
refreshEnforcement()
|
refreshEnforcement()
|
||||||
}
|
}
|
||||||
unblockCandidate = nil
|
unblockCandidate = nil
|
||||||
@@ -74,15 +74,22 @@ struct AppsHomeView: View {
|
|||||||
private func ruleList(now: Date) -> some View {
|
private func ruleList(now: Date) -> some View {
|
||||||
List {
|
List {
|
||||||
blockedSection(now: now)
|
blockedSection(now: now)
|
||||||
|
usageSection(now: now)
|
||||||
rulesSection(now: now)
|
rulesSection(now: now)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Status with the day's usage folded in, so limit rules whose budget is
|
||||||
|
/// spent count as actively blocking.
|
||||||
|
private func liveStatus(for rule: BlockingRule, now: Date) -> RuleStatus {
|
||||||
|
rule.status(at: now, usage: enforcer.usage(for: rule, at: now))
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Blocked Apps
|
// MARK: - Blocked Apps
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func blockedSection(now: Date) -> some View {
|
private func blockedSection(now: Date) -> some View {
|
||||||
let blocking = rules.filter { $0.status(at: now).isActive }
|
let blocking = rules.filter { liveStatus(for: $0, now: now).isActive }
|
||||||
Section {
|
Section {
|
||||||
if blocking.isEmpty {
|
if blocking.isEmpty {
|
||||||
Text("Nothing is blocked right now.")
|
Text("Nothing is blocked right now.")
|
||||||
@@ -100,7 +107,7 @@ struct AppsHomeView: View {
|
|||||||
|
|
||||||
private func blockedRow(for rule: BlockingRule, now: Date) -> some View {
|
private func blockedRow(for rule: BlockingRule, now: Date) -> some View {
|
||||||
Button {
|
Button {
|
||||||
if RulePolicy.canUnblock(rule, at: now) {
|
if RulePolicy.canUnblock(rule, usage: enforcer.usage(for: rule, at: now), at: now) {
|
||||||
unblockCandidate = rule
|
unblockCandidate = rule
|
||||||
} else {
|
} else {
|
||||||
hardModeBlockedAttempt = true
|
hardModeBlockedAttempt = true
|
||||||
@@ -120,6 +127,53 @@ struct AppsHomeView: View {
|
|||||||
.accessibilityIdentifier("blockedTile-\(rule.name)")
|
.accessibilityIdentifier("blockedTile-\(rule.name)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Usage
|
||||||
|
|
||||||
|
/// Live tracking for every limit rule scheduled today: how much of the
|
||||||
|
/// daily budget is spent and what remains.
|
||||||
|
@ViewBuilder
|
||||||
|
private func usageSection(now: Date) -> some View {
|
||||||
|
let tracked = rules.filter {
|
||||||
|
$0.kind != .schedule && $0.isEnabled && $0.isScheduledToday(at: now)
|
||||||
|
}
|
||||||
|
if !tracked.isEmpty {
|
||||||
|
Section {
|
||||||
|
ForEach(tracked) { rule in
|
||||||
|
usageRow(for: rule, now: now)
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Usage").textCase(nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func usageRow(for rule: BlockingRule, now: Date) -> some View {
|
||||||
|
let usage = enforcer.usage(for: rule, at: now) ?? RuleUsage()
|
||||||
|
let isPaused =
|
||||||
|
if case .paused = liveStatus(for: rule, now: now) { true } else { false }
|
||||||
|
return HStack {
|
||||||
|
Image(systemName: rule.kind.symbolName)
|
||||||
|
.foregroundStyle(.tint)
|
||||||
|
.frame(width: 28)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(rule.name)
|
||||||
|
.foregroundStyle(Color.primary)
|
||||||
|
Text(UsageDisplay.subtitle(for: rule, usage: usage))
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(Color.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text(UsageDisplay.remainingLabel(for: rule, usage: usage, isPaused: isPaused))
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(
|
||||||
|
rule.limitReached(given: usage) && !isPaused
|
||||||
|
? AnyShapeStyle(Color.red) : AnyShapeStyle(Color.secondary)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.accessibilityElement(children: .combine)
|
||||||
|
.accessibilityIdentifier("usageRow-\(rule.name)")
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Rules
|
// MARK: - Rules
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -147,7 +201,7 @@ struct AppsHomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func ruleRow(for rule: BlockingRule, now: Date) -> some View {
|
private func ruleRow(for rule: BlockingRule, now: Date) -> some View {
|
||||||
let status = rule.status(at: now)
|
let status = liveStatus(for: rule, now: now)
|
||||||
return Button {
|
return Button {
|
||||||
detailRule = rule
|
detailRule = rule
|
||||||
} label: {
|
} label: {
|
||||||
@@ -179,11 +233,15 @@ struct AppsHomeView: View {
|
|||||||
private func statusText(for rule: BlockingRule, status: RuleStatus, now: Date) -> String {
|
private func statusText(for rule: BlockingRule, status: RuleStatus, now: Date) -> String {
|
||||||
switch rule.kind {
|
switch rule.kind {
|
||||||
case .schedule:
|
case .schedule:
|
||||||
status.label(relativeTo: now)
|
return status.label(relativeTo: now)
|
||||||
case .timeLimit:
|
case .timeLimit:
|
||||||
rule.isEnabled ? "\(rule.dailyLimitMinutes)m / day" : "Disabled"
|
// While blocking (or paused/disabled) show the live state; the
|
||||||
|
// budget is only informative while it is still being spent.
|
||||||
|
if case .upcoming = status { return "\(rule.dailyLimitMinutes)m / day" }
|
||||||
|
return status.label(relativeTo: now)
|
||||||
case .openLimit:
|
case .openLimit:
|
||||||
rule.isEnabled ? "\(rule.maxOpens) opens / day" : "Disabled"
|
if case .upcoming = status { return "\(rule.maxOpens) opens / day" }
|
||||||
|
return status.label(relativeTo: now)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ struct RuleDetailSheet: View {
|
|||||||
|
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
|
@Environment(RuleEnforcer.self) private var enforcer
|
||||||
@State private var isEditing = false
|
@State private var isEditing = false
|
||||||
@State private var pendingDeletion = false
|
@State private var pendingDeletion = false
|
||||||
|
|
||||||
@@ -50,13 +51,14 @@ struct RuleDetailSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func detailList(now: Date) -> some View {
|
private func detailList(now: Date) -> some View {
|
||||||
let status = rule.status(at: now)
|
let usage = enforcer.usage(for: rule, at: now)
|
||||||
|
let status = rule.status(at: now, usage: usage)
|
||||||
return List {
|
return List {
|
||||||
Section {
|
Section {
|
||||||
detailRows
|
detailRows
|
||||||
}
|
}
|
||||||
Section {
|
Section {
|
||||||
if RulePolicy.canEdit(rule, at: now) {
|
if RulePolicy.canEdit(rule, usage: usage, at: now) {
|
||||||
Button {
|
Button {
|
||||||
isEditing = true
|
isEditing = true
|
||||||
} label: {
|
} label: {
|
||||||
|
|||||||
222
OpenAppLockTests/UsageTests.swift
Normal file
222
OpenAppLockTests/UsageTests.swift
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
//
|
||||||
|
// UsageTests.swift
|
||||||
|
// OpenAppLockTests
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
|
||||||
|
@testable import OpenAppLock
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("Usage ledger")
|
||||||
|
struct UsageLedgerTests {
|
||||||
|
private func makeLedger() -> UsageLedger {
|
||||||
|
let suiteName = "usage-tests-\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
|
defaults.removePersistentDomain(forName: suiteName)
|
||||||
|
return UsageLedger(defaults: defaults)
|
||||||
|
}
|
||||||
|
|
||||||
|
let monday = date(2025, 1, 6, 10, 0)
|
||||||
|
let tuesday = date(2025, 1, 7, 10, 0)
|
||||||
|
|
||||||
|
@Test("Day keys are calendar dates")
|
||||||
|
func dayKey() {
|
||||||
|
#expect(UsageLedger.dayKey(for: monday, calendar: utc) == "2025-01-06")
|
||||||
|
#expect(UsageLedger.dayKey(for: date(2025, 12, 31, 23, 59), calendar: utc) == "2025-12-31")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Usage defaults to zero and round-trips")
|
||||||
|
func roundTrip() {
|
||||||
|
let ledger = makeLedger()
|
||||||
|
let ruleID = UUID()
|
||||||
|
#expect(ledger.usage(for: ruleID, onDayContaining: monday, calendar: utc) == RuleUsage())
|
||||||
|
|
||||||
|
ledger.setUsage(
|
||||||
|
RuleUsage(minutesUsed: 12, opensUsed: 3),
|
||||||
|
for: ruleID, onDayContaining: monday, calendar: utc
|
||||||
|
)
|
||||||
|
let read = ledger.usage(for: ruleID, onDayContaining: monday, calendar: utc)
|
||||||
|
#expect(read.minutesUsed == 12)
|
||||||
|
#expect(read.opensUsed == 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Recorded minutes are monotonic per day")
|
||||||
|
func monotonicMinutes() {
|
||||||
|
let ledger = makeLedger()
|
||||||
|
let ruleID = UUID()
|
||||||
|
ledger.recordMinutesUsed(10, for: ruleID, onDayContaining: monday, calendar: utc)
|
||||||
|
ledger.recordMinutesUsed(7, for: ruleID, onDayContaining: monday, calendar: utc)
|
||||||
|
#expect(ledger.usage(for: ruleID, onDayContaining: monday, calendar: utc).minutesUsed == 10)
|
||||||
|
ledger.recordMinutesUsed(25, for: ruleID, onDayContaining: monday, calendar: utc)
|
||||||
|
#expect(ledger.usage(for: ruleID, onDayContaining: monday, calendar: utc).minutesUsed == 25)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Opens increment per day")
|
||||||
|
func opensIncrement() {
|
||||||
|
let ledger = makeLedger()
|
||||||
|
let ruleID = UUID()
|
||||||
|
ledger.recordOpen(for: ruleID, onDayContaining: monday, calendar: utc)
|
||||||
|
ledger.recordOpen(for: ruleID, onDayContaining: monday, calendar: utc)
|
||||||
|
#expect(ledger.usage(for: ruleID, onDayContaining: monday, calendar: utc).opensUsed == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Usage is separated by day and rule")
|
||||||
|
func separation() {
|
||||||
|
let ledger = makeLedger()
|
||||||
|
let first = UUID()
|
||||||
|
let second = UUID()
|
||||||
|
ledger.recordMinutesUsed(30, for: first, onDayContaining: monday, calendar: utc)
|
||||||
|
#expect(ledger.usage(for: first, onDayContaining: tuesday, calendar: utc) == RuleUsage())
|
||||||
|
#expect(ledger.usage(for: second, onDayContaining: monday, calendar: utc) == RuleUsage())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("Usage-aware rule status")
|
||||||
|
struct UsageStatusTests {
|
||||||
|
let mondayMorning = date(2025, 1, 6, 10, 0)
|
||||||
|
|
||||||
|
private func timeLimitRule(limit: Int = 45) -> BlockingRule {
|
||||||
|
BlockingRule(name: "Time Keeper", kind: .timeLimit, days: Weekday.everyDay,
|
||||||
|
dailyLimitMinutes: limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openLimitRule(maxOpens: Int = 5) -> BlockingRule {
|
||||||
|
BlockingRule(name: "Gate Keeper", kind: .openLimit, days: Weekday.everyDay,
|
||||||
|
maxOpens: maxOpens)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A time-limit rule with budget left is not active")
|
||||||
|
func budgetLeftNotActive() {
|
||||||
|
let rule = timeLimitRule()
|
||||||
|
let status = rule.status(at: mondayMorning, calendar: utc, usage: RuleUsage(minutesUsed: 20))
|
||||||
|
#expect(!status.isActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A time-limit rule blocks until midnight once its budget is spent")
|
||||||
|
func spentBudgetBlocksUntilMidnight() {
|
||||||
|
let rule = timeLimitRule(limit: 45)
|
||||||
|
let status = rule.status(at: mondayMorning, calendar: utc, usage: RuleUsage(minutesUsed: 45))
|
||||||
|
#expect(status == .active(until: date(2025, 1, 7, 0, 0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("An open-limit rule blocks once opens are exhausted")
|
||||||
|
func exhaustedOpensBlock() {
|
||||||
|
let rule = openLimitRule(maxOpens: 5)
|
||||||
|
let status = rule.status(at: mondayMorning, calendar: utc, usage: RuleUsage(opensUsed: 5))
|
||||||
|
#expect(status == .active(until: date(2025, 1, 7, 0, 0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A spent budget on a disabled day does not block")
|
||||||
|
func disabledDayDoesNotBlock() {
|
||||||
|
let rule = timeLimitRule()
|
||||||
|
rule.days = Weekday.weekends
|
||||||
|
let status = rule.status(at: mondayMorning, calendar: utc, usage: RuleUsage(minutesUsed: 99))
|
||||||
|
#expect(!status.isActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Unblocking a limit-blocked rule pauses it until midnight")
|
||||||
|
func unblockPausesUntilMidnight() {
|
||||||
|
let rule = timeLimitRule(limit: 45)
|
||||||
|
let usage = RuleUsage(minutesUsed: 45)
|
||||||
|
#expect(RulePolicy.canUnblock(rule, usage: usage, at: mondayMorning, calendar: utc))
|
||||||
|
#expect(RulePolicy.unblock(rule, usage: usage, at: mondayMorning, calendar: utc))
|
||||||
|
#expect(rule.pausedUntil == date(2025, 1, 7, 0, 0))
|
||||||
|
#expect(
|
||||||
|
rule.status(at: mondayMorning, calendar: utc, usage: usage)
|
||||||
|
== .paused(until: date(2025, 1, 7, 0, 0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Hard Mode locks a limit-blocked rule and its app lists")
|
||||||
|
func hardModeLocksLimitBlock() {
|
||||||
|
let rule = timeLimitRule(limit: 45)
|
||||||
|
rule.hardMode = true
|
||||||
|
let usage = RuleUsage(minutesUsed: 45)
|
||||||
|
#expect(RulePolicy.isHardLocked(rule, usage: usage, at: mondayMorning, calendar: utc))
|
||||||
|
#expect(!RulePolicy.canUnblock(rule, usage: usage, at: mondayMorning, calendar: utc))
|
||||||
|
#expect(
|
||||||
|
!RulePolicy.canEditAppLists(
|
||||||
|
rules: [rule], usageFor: { _ in usage }, at: mondayMorning, calendar: utc))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("Enforcement of limit rules")
|
||||||
|
struct UsageEnforcementTests {
|
||||||
|
let mondayMorning = date(2025, 1, 6, 10, 0)
|
||||||
|
|
||||||
|
@Test("A spent time-limit rule is shielded in Block mode")
|
||||||
|
func shieldsSpentTimeLimit() {
|
||||||
|
let shields = MockShieldController()
|
||||||
|
let ledger = MockUsageLedger()
|
||||||
|
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
|
||||||
|
let rule = BlockingRule(
|
||||||
|
name: "Time Keeper", kind: .timeLimit, days: Weekday.everyDay, dailyLimitMinutes: 45)
|
||||||
|
ledger.usageByRule[rule.id] = RuleUsage(minutesUsed: 45)
|
||||||
|
|
||||||
|
enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc)
|
||||||
|
|
||||||
|
#expect(shields.shieldedRuleIDs == [rule.id])
|
||||||
|
#expect(shields.appliedModes[rule.id] == .block)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("A limit rule with budget left is not shielded")
|
||||||
|
func leavesUnspentLimitAlone() {
|
||||||
|
let shields = MockShieldController()
|
||||||
|
let ledger = MockUsageLedger()
|
||||||
|
let enforcer = RuleEnforcer(shields: shields, usage: ledger)
|
||||||
|
let rule = BlockingRule(
|
||||||
|
name: "Gate Keeper", kind: .openLimit, days: Weekday.everyDay, maxOpens: 5)
|
||||||
|
ledger.usageByRule[rule.id] = RuleUsage(opensUsed: 2)
|
||||||
|
|
||||||
|
enforcer.refresh(rules: [rule], at: mondayMorning, calendar: utc)
|
||||||
|
|
||||||
|
#expect(shields.shieldedRuleIDs.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Suite("Usage display strings")
|
||||||
|
struct UsageDisplayTests {
|
||||||
|
let timeRule = BlockingRule(
|
||||||
|
name: "Time Keeper", kind: .timeLimit, days: Weekday.everyDay, dailyLimitMinutes: 45)
|
||||||
|
let openRule = BlockingRule(
|
||||||
|
name: "Gate Keeper", kind: .openLimit, days: Weekday.everyDay, maxOpens: 5)
|
||||||
|
|
||||||
|
@Test("Time-limit rows show minutes used and remaining")
|
||||||
|
func timeLimitStrings() {
|
||||||
|
let usage = RuleUsage(minutesUsed: 18)
|
||||||
|
#expect(UsageDisplay.subtitle(for: timeRule, usage: usage) == "18m of 45m used today")
|
||||||
|
#expect(UsageDisplay.remainingLabel(for: timeRule, usage: usage, isPaused: false) == "27m left")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Open-limit rows show opens used and remaining")
|
||||||
|
func openLimitStrings() {
|
||||||
|
let usage = RuleUsage(opensUsed: 2)
|
||||||
|
#expect(UsageDisplay.subtitle(for: openRule, usage: usage) == "2 of 5 opens today")
|
||||||
|
#expect(UsageDisplay.remainingLabel(for: openRule, usage: usage, isPaused: false) == "3 opens left")
|
||||||
|
|
||||||
|
let oneLeft = RuleUsage(opensUsed: 4)
|
||||||
|
#expect(UsageDisplay.remainingLabel(for: openRule, usage: oneLeft, isPaused: false) == "1 open left")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Spent budgets read as blocked, or unblocked while paused")
|
||||||
|
func exhaustedStrings() {
|
||||||
|
let spent = RuleUsage(minutesUsed: 45)
|
||||||
|
#expect(
|
||||||
|
UsageDisplay.remainingLabel(for: timeRule, usage: spent, isPaused: false)
|
||||||
|
== "Blocked until tomorrow")
|
||||||
|
#expect(
|
||||||
|
UsageDisplay.remainingLabel(for: timeRule, usage: spent, isPaused: true)
|
||||||
|
== "Unblocked until tomorrow")
|
||||||
|
#expect(UsageDisplay.subtitle(for: timeRule, usage: spent) == "45m of 45m used today")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("Overshoot clamps to the budget")
|
||||||
|
func overshootClamps() {
|
||||||
|
let over = RuleUsage(minutesUsed: 60)
|
||||||
|
#expect(UsageDisplay.subtitle(for: timeRule, usage: over) == "45m of 45m used today")
|
||||||
|
}
|
||||||
|
}
|
||||||
49
OpenAppLockUITests/UsageUITests.swift
Normal file
49
OpenAppLockUITests/UsageUITests.swift
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
//
|
||||||
|
// UsageUITests.swift
|
||||||
|
// OpenAppLockUITests
|
||||||
|
//
|
||||||
|
|
||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// The Usage section under Blocked Apps — seeded with limit rules at various
|
||||||
|
/// budget states ("Time Keeper" 18m/45m, "Gate Keeper" 2/5 opens,
|
||||||
|
/// "Doom Scroll" spent → blocked).
|
||||||
|
final class UsageUITests: XCTestCase {
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func testUsageSectionShowsTimeAndOpenBudgets() throws {
|
||||||
|
let app = XCUIApplication.launchOpenAppLock(seedScenario: "limits")
|
||||||
|
|
||||||
|
XCTAssertTrue(app.staticTexts["Usage"].waitToAppear().exists)
|
||||||
|
|
||||||
|
let timeRow = app.element("usageRow-Time Keeper").waitToAppear()
|
||||||
|
XCTAssertTrue(timeRow.label.contains("18m of 45m used today"), "Got: \(timeRow.label)")
|
||||||
|
XCTAssertTrue(timeRow.label.contains("27m left"), "Got: \(timeRow.label)")
|
||||||
|
|
||||||
|
let openRow = app.element("usageRow-Gate Keeper").waitToAppear()
|
||||||
|
XCTAssertTrue(openRow.label.contains("2 of 5 opens today"), "Got: \(openRow.label)")
|
||||||
|
XCTAssertTrue(openRow.label.contains("3 opens left"), "Got: \(openRow.label)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSpentBudgetShowsAsBlockedUntilTomorrow() throws {
|
||||||
|
let app = XCUIApplication.launchOpenAppLock(seedScenario: "limits")
|
||||||
|
|
||||||
|
let spentRow = app.element("usageRow-Doom Scroll").waitToAppear()
|
||||||
|
XCTAssertTrue(spentRow.label.contains("Blocked until tomorrow"), "Got: \(spentRow.label)")
|
||||||
|
// A spent budget is a real block: the rule surfaces in Blocked Apps.
|
||||||
|
XCTAssertTrue(app.buttons["blockedTile-Doom Scroll"].waitToAppear().exists)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testSpentBudgetCanBeUnblockedUntilTomorrow() throws {
|
||||||
|
let app = XCUIApplication.launchOpenAppLock(seedScenario: "limits")
|
||||||
|
|
||||||
|
app.buttons["blockedTile-Doom Scroll"].waitToAppear().tap()
|
||||||
|
app.sheets.buttons["Unblock"].waitToAppear().tap()
|
||||||
|
|
||||||
|
app.staticTexts["nothingBlockedLabel"].waitToAppear()
|
||||||
|
let row = app.element("usageRow-Doom Scroll").waitToAppear()
|
||||||
|
XCTAssertTrue(row.label.contains("Unblocked until tomorrow"), "Got: \(row.label)")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user