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>
151 lines
6.0 KiB
Swift
151 lines
6.0 KiB
Swift
//
|
||
// RuleStatusTests.swift
|
||
// OpenAppLockTests
|
||
//
|
||
|
||
import Foundation
|
||
import Testing
|
||
|
||
@testable import OpenAppLock
|
||
|
||
@MainActor
|
||
@Suite("Rule status derivation and labels")
|
||
struct RuleStatusTests {
|
||
/// 09:00–17: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))
|
||
}
|
||
}
|