Files
OpenAppLock/OpenAppLockTests/RuleStatusTests.swift
Brendan Chen c4969b05ec fix: show daily budget instead of bogus clock countdown for limit rules
Time-limit and open-limit rules reuse BlockingRule's startMinutes/
endMinutes, which default to 09:00-17:00. Those fields are meaningless
for limit rules (the budget applies all day, resetting at midnight), but
status() still falls through to schedule.nextStart(), so an idle limit
rule reported .upcoming(startsAt: next 09:00) — rendered in the detail
sheet header as "Starts in 22h".

The home card already special-cased this; the detail sheet rendered the
raw status label, so the misleading countdown leaked there. Centralize a
kind-aware BlockingRule.statusLabel(for:relativeTo:) used by both the
card and the detail header: limit rules that aren't blocking show their
daily budget ("45m / day", "5 opens / day"); schedule rules and any
blocking/paused/dormant rule keep the live status label.

Verified on simulator: the Time Keeper detail header now reads
"Time Limit, 45m / day". 173 tests pass.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-13 11:55:19 -04:00

151 lines
6.0 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// RuleStatusTests.swift
// OpenAppLockTests
//
import Foundation
import Testing
@testable import OpenAppLock
@MainActor
@Suite("Rule status derivation and labels")
struct RuleStatusTests {
/// 09:0017:00 weekdays schedule rule, like the video's "Work Time".
func workTime(hardMode: Bool = false) -> BlockingRule {
BlockingRule(name: "Work Time", hardMode: hardMode)
}
@Test("Disabled rules report disabled regardless of the clock")
func disabled() {
let rule = workTime()
rule.isEnabled = false
#expect(rule.status(at: date(2025, 1, 6, 10, 0), calendar: utc) == .disabled)
}
@Test("Enabled rule inside its window is active until the window ends")
func active() {
let status = workTime().status(at: date(2025, 1, 6, 10, 0), calendar: utc)
#expect(status == .active(until: date(2025, 1, 6, 17, 0)))
#expect(status.isActive)
}
@Test("Enabled rule outside its window is upcoming")
func upcoming() {
let status = workTime().status(at: date(2025, 1, 6, 18, 0), calendar: utc)
#expect(status == .upcoming(startsAt: date(2025, 1, 7, 9, 0)))
#expect(!status.isActive)
}
@Test("A rule with no days is dormant")
func dormant() {
let rule = workTime()
rule.days = []
#expect(rule.status(at: date(2025, 1, 6, 10, 0), calendar: utc) == .dormant)
}
@Test("A paused rule reports paused until the window ends")
func paused() {
let rule = workTime()
rule.pausedUntil = date(2025, 1, 6, 17, 0)
let status = rule.status(at: date(2025, 1, 6, 10, 0), calendar: utc)
#expect(status == .paused(until: date(2025, 1, 6, 17, 0)))
#expect(!status.isActive)
}
@Test("An expired pause no longer affects status")
func expiredPause() {
let rule = workTime()
rule.pausedUntil = date(2025, 1, 6, 9, 30)
let status = rule.status(at: date(2025, 1, 6, 10, 0), calendar: utc)
#expect(status == .active(until: date(2025, 1, 6, 17, 0)))
}
@Test("Time-limit rules are never schedule-active")
func timeLimitNeverActive() {
let rule = BlockingRule(name: "Time Keeper", kind: .timeLimit)
let status = rule.status(at: date(2025, 1, 6, 10, 0), calendar: utc)
#expect(!status.isActive)
if case .upcoming = status {} else {
Issue.record("Expected upcoming, got \(status)")
}
}
@Test("Active label matches the reference style: hours round up")
func activeLabel() {
// 11:28 17:00 is 5h32m; the reference app shows "6h left".
let status = workTime().status(at: date(2025, 1, 6, 11, 28), calendar: utc)
#expect(status.label(relativeTo: date(2025, 1, 6, 11, 28)) == "6h left")
}
@Test("Upcoming label matches the reference style")
func upcomingLabel() {
// Friday 11:28 Saturday 09:00 is 21h32m; the reference shows "Starts in 22h".
let weekend = BlockingRule(name: "Weekend Zen", days: Weekday.weekends)
let friday = date(2025, 1, 10, 11, 28)
#expect(weekend.status(at: friday, calendar: utc).label(relativeTo: friday) == "Starts in 22h")
}
@Test("Countdown formatting tiers", arguments: [
(30, "30m"), (59, "59m"), (60, "1h"), (90, "2h"),
(6 * 60, "6h"), (47 * 60, "47h"), (49 * 60, "2d"), (75 * 60, "3d"),
])
func countdownTiers(minutes: Int, expected: String) {
let now = date(2025, 1, 6, 0, 0)
let target = utc.date(byAdding: .minute, value: minutes, to: now)!
#expect(RuleStatus.countdown(from: now, to: target) == expected)
}
@Test("Countdown never reads below one minute")
func countdownFloor() {
let now = date(2025, 1, 6, 0, 0)
#expect(RuleStatus.countdown(from: now, to: now.addingTimeInterval(5)) == "1m")
}
@Test("Static labels")
func staticLabels() {
let now = date(2025, 1, 6, 0, 0)
#expect(RuleStatus.disabled.label(relativeTo: now) == "Disabled")
#expect(RuleStatus.dormant.label(relativeTo: now) == "No days selected")
#expect(RuleStatus.paused(until: now).label(relativeTo: now) == "Paused")
}
// MARK: - Kind-aware display label
/// A non-blocking time-limit rule has no clock window, so it must show its
/// 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 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")
}
@Test("Idle open-limit rule shows its daily opens budget")
func openLimitDisplayLabel() {
let rule = BlockingRule(name: "Gate Keeper", kind: .openLimit, 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")
}
@Test("Schedule rule still shows the clock countdown")
func scheduleDisplayLabelUnchanged() {
let weekend = BlockingRule(name: "Weekend Zen", days: Weekday.weekends)
let friday = date(2025, 1, 10, 11, 28)
let status = weekend.status(at: friday, calendar: utc)
#expect(weekend.statusLabel(for: status, relativeTo: friday) == "Starts in 22h")
}
@Test("A spent time-limit budget still shows the blocked countdown")
func timeLimitBlockingDisplayLabel() {
let rule = BlockingRule(name: "Time Keeper", kind: .timeLimit, 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)
// Stays on the live countdown ("Xh left"), not overridden to the budget.
#expect(rule.statusLabel(for: status, relativeTo: now) == status.label(relativeTo: now))
}
}