Files
2026-07-13 12:22:33 +08:00

415 lines
15 KiB
Swift
Raw Permalink 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.
import CodexBarCore
import Foundation
import Testing
@testable import CodexBar
struct UsagePaceTextTests {
private static let localizedKeys: [String] = [
"Pace: %@",
"Pace: %@ · %@",
"On pace",
"%d%% in deficit",
"%d%% in reserve",
"Lasts until reset",
"Projected empty now",
"Projected empty in %@",
"Runs out now",
"Runs out in %@",
"1.5× headroom",
"≈ %d%% run-out risk",
"%@ · %@",
]
@Test
func `weekly pace detail provides left right labels`() throws {
let now = Date(timeIntervalSince1970: 0)
let window = RateWindow(
usedPercent: 50,
windowMinutes: 10080,
resetsAt: now.addingTimeInterval(4 * 24 * 3600),
resetDescription: nil)
let pace = try #require(UsagePace.weekly(window: window, now: now))
let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now)
#expect(detail.leftLabel == "7% in deficit")
#expect(detail.rightLabel == "Runs out in 3d")
}
@Test
func `weekly pace detail treats rounded zero delta as on pace`() {
let now = Date(timeIntervalSince1970: 0)
let pace = UsagePace(
stage: .slightlyBehind,
deltaPercent: -0.4,
expectedUsedPercent: 50.4,
actualUsedPercent: 50,
etaSeconds: nil,
willLastToReset: true)
let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now)
#expect(detail.leftLabel == "On pace")
}
@Test
func `weekly pace detail reports lasts until reset`() throws {
let now = Date(timeIntervalSince1970: 0)
let window = RateWindow(
usedPercent: 10,
windowMinutes: 10080,
resetsAt: now.addingTimeInterval(4 * 24 * 3600),
resetDescription: nil)
let pace = try #require(UsagePace.weekly(window: window, now: now))
let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now)
#expect(detail.leftLabel == "33% in reserve")
#expect(detail.rightLabel == "Lasts until reset · 1.5× headroom")
}
@Test
func `weekly pace summary formats single line text`() throws {
let now = Date(timeIntervalSince1970: 0)
let window = RateWindow(
usedPercent: 50,
windowMinutes: 10080,
resetsAt: now.addingTimeInterval(4 * 24 * 3600),
resetDescription: nil)
let pace = try #require(UsagePace.weekly(window: window, now: now))
let summary = UsagePaceText.weeklySummary(provider: .codex, pace: pace, now: now)
#expect(summary == "Pace: 7% in deficit · Runs out in 3d")
}
@Test
func `weekly pace detail reports capped speed headroom when under pace`() throws {
let now = Date(timeIntervalSince1970: 0)
let window = RateWindow(
usedPercent: 20,
windowMinutes: 10080,
resetsAt: now.addingTimeInterval(3 * 24 * 3600),
resetDescription: nil)
let pace = try #require(UsagePace.weekly(window: window, now: now))
let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now)
#expect(detail.leftLabel == "37% in reserve")
#expect(detail.rightLabel == "Lasts until reset · 1.5× headroom")
}
@Test
func `weekly pace detail limits headroom hint to Codex`() throws {
let now = Date(timeIntervalSince1970: 0)
let window = RateWindow(
usedPercent: 20,
windowMinutes: 10080,
resetsAt: now.addingTimeInterval(3 * 24 * 3600),
resetDescription: nil)
let pace = try #require(UsagePace.weekly(window: window, now: now))
let detail = UsagePaceText.weeklyDetail(provider: .claude, pace: pace, now: now)
#expect(detail.rightLabel == "Lasts until reset")
}
@Test
func `weekly pace detail reports remaining headroom late in window`() throws {
let now = Date(timeIntervalSince1970: 0)
let window = RateWindow(
usedPercent: 70,
windowMinutes: 10080,
resetsAt: now.addingTimeInterval(0.7 * 24 * 3600),
resetDescription: nil)
let pace = try #require(UsagePace.weekly(window: window, now: now))
let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now)
#expect(detail.leftLabel == "20% in reserve")
#expect(detail.rightLabel == "Lasts until reset · 1.5× headroom")
}
@Test
func `reported weekly state renders deficit and run out headline`() throws {
let now = Date(timeIntervalSince1970: 0)
let window = RateWindow(
usedPercent: 88,
windowMinutes: 10080,
resetsAt: now.addingTimeInterval((2 * 24 + 19) * 3600),
resetDescription: nil)
let pace = try #require(UsagePace.weekly(window: window, now: now, workDays: nil))
let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now)
#expect(detail.leftLabel == "28% in deficit")
#expect(detail.rightLabel == "Runs out in 13h 47m")
#expect(detail.rightLabel?.contains("Lasts until reset") == false)
}
@Test
func `weekly pace detail formats rounded risk when available`() {
let now = Date(timeIntervalSince1970: 0)
let pace = UsagePace(
stage: .ahead,
deltaPercent: 8,
expectedUsedPercent: 42,
actualUsedPercent: 50,
etaSeconds: 2 * 24 * 3600,
willLastToReset: false,
runOutProbability: 0.683)
let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now)
#expect(detail.rightLabel == "Runs out in 2d · ≈ 70% run-out risk")
}
@Test
func `weekly pace detail does not combine lasts until reset with run out risk`() {
let now = Date(timeIntervalSince1970: 0)
let pace = UsagePace(
stage: .slightlyBehind,
deltaPercent: -9,
expectedUsedPercent: 21,
actualUsedPercent: 12,
etaSeconds: nil,
willLastToReset: true,
runOutProbability: 0.45)
let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now)
#expect(detail.leftLabel == "9% in reserve")
#expect(detail.rightLabel == "≈ 45% run-out risk")
}
@Test
func `weekly pace detail keeps lasts until reset only when rounded risk is zero`() {
let now = Date(timeIntervalSince1970: 0)
let pace = UsagePace(
stage: .farBehind,
deltaPercent: -30,
expectedUsedPercent: 40,
actualUsedPercent: 10,
etaSeconds: nil,
willLastToReset: true,
runOutProbability: 0.02,
speedMultiplierToReset: 4)
let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now)
#expect(detail.rightLabel == "Lasts until reset · 1.5× headroom · ≈ 0% run-out risk")
}
@Test
func `weekly pace detail prefers risk over lasts until reset when rounded risk is material`() {
let now = Date(timeIntervalSince1970: 0)
let pace = UsagePace(
stage: .slightlyBehind,
deltaPercent: -9,
expectedUsedPercent: 21,
actualUsedPercent: 12,
etaSeconds: nil,
willLastToReset: true,
runOutProbability: 0.03)
let detail = UsagePaceText.weeklyDetail(provider: .codex, pace: pace, now: now)
#expect(detail.rightLabel == "≈ 5% run-out risk")
}
// MARK: - Session pace (5-hour window)
@Test
func `session pace detail provides left right labels`() {
let now = Date(timeIntervalSince1970: 0)
// 300-minute window, 2h remaining => 3h elapsed out of 5h
// expected = 60%, actual = 80% => 20% ahead (in deficit)
let window = RateWindow(
usedPercent: 80,
windowMinutes: 300,
resetsAt: now.addingTimeInterval(2 * 3600),
resetDescription: nil)
let detail = UsagePaceText.sessionDetail(provider: .claude, window: window, now: now)
#expect(detail != nil)
#expect(detail?.leftLabel == "20% in deficit")
#expect(detail?.rightLabel == "Projected empty in 45m")
#expect(detail?.stage == .farAhead)
}
@Test
func `Claude session pace does not show Codex headroom`() {
let now = Date(timeIntervalSince1970: 0)
// 300-minute window, 2h remaining => 3h elapsed
// expected = 60%, actual = 10% => far behind (in reserve)
let window = RateWindow(
usedPercent: 10,
windowMinutes: 300,
resetsAt: now.addingTimeInterval(2 * 3600),
resetDescription: nil)
let detail = UsagePaceText.sessionDetail(provider: .claude, window: window, now: now)
#expect(detail != nil)
#expect(detail?.leftLabel == "50% in reserve")
#expect(detail?.rightLabel == "Lasts until reset")
}
@Test
func `Codex session pace shows conservative headroom`() {
let now = Date(timeIntervalSince1970: 0)
let window = RateWindow(
usedPercent: 10,
windowMinutes: 300,
resetsAt: now.addingTimeInterval(2 * 3600),
resetDescription: nil)
let detail = UsagePaceText.sessionDetail(provider: .codex, window: window, now: now)
#expect(detail?.rightLabel == "Lasts until reset · 1.5× headroom")
}
@Test
func `session pace summary formats single line text`() {
let now = Date(timeIntervalSince1970: 0)
let window = RateWindow(
usedPercent: 80,
windowMinutes: 300,
resetsAt: now.addingTimeInterval(2 * 3600),
resetDescription: nil)
let summary = UsagePaceText.sessionSummary(provider: .claude, window: window, now: now)
#expect(summary == "Pace: 20% in deficit · Projected empty in 45m")
}
@Test
func `session pace detail supports Ollama five hour window`() {
let now = Date(timeIntervalSince1970: 0)
let window = RateWindow(
usedPercent: 80,
windowMinutes: 300,
resetsAt: now.addingTimeInterval(2 * 3600),
resetDescription: nil)
let detail = UsagePaceText.sessionDetail(provider: .ollama, window: window, now: now)
#expect(detail?.leftLabel == "20% in deficit")
#expect(detail?.rightLabel == "Projected empty in 45m")
}
@Test
func `session pace detail supports Antigravity five hour window`() {
let now = Date(timeIntervalSince1970: 0)
let window = RateWindow(
usedPercent: 80,
windowMinutes: 300,
resetsAt: now.addingTimeInterval(2 * 3600),
resetDescription: nil)
let detail = UsagePaceText.sessionDetail(provider: .antigravity, window: window, now: now)
#expect(detail?.leftLabel == "20% in deficit")
#expect(detail?.rightLabel == "Projected empty in 45m")
}
@Test
func `session pace detail hides Antigravity weekly window`() {
let now = Date(timeIntervalSince1970: 0)
let window = RateWindow(
usedPercent: 80,
windowMinutes: 10080,
resetsAt: now.addingTimeInterval(2 * 24 * 3600),
resetDescription: nil)
let detail = UsagePaceText.sessionDetail(provider: .antigravity, window: window, now: now)
#expect(detail == nil)
}
@Test
func `session pace detail hides Ollama window without explicit duration`() {
let now = Date(timeIntervalSince1970: 0)
let window = RateWindow(
usedPercent: 80,
windowMinutes: nil,
resetsAt: now.addingTimeInterval(2 * 3600),
resetDescription: nil)
let detail = UsagePaceText.sessionDetail(provider: .ollama, window: window, now: now)
#expect(detail == nil)
}
@Test
func `session pace detail hides for unsupported provider`() {
let now = Date(timeIntervalSince1970: 0)
let window = RateWindow(
usedPercent: 50,
windowMinutes: 300,
resetsAt: now.addingTimeInterval(2 * 3600),
resetDescription: nil)
let detail = UsagePaceText.sessionDetail(provider: .zai, window: window, now: now)
#expect(detail == nil)
}
@Test
func `session pace detail hides when reset is missing`() {
let now = Date(timeIntervalSince1970: 0)
let window = RateWindow(
usedPercent: 50,
windowMinutes: 300,
resetsAt: nil,
resetDescription: nil)
let detail = UsagePaceText.sessionDetail(provider: .claude, window: window, now: now)
#expect(detail == nil)
}
@Test
func `usage pace text localization keys exist in en and zh Hans with matching placeholders`() throws {
let root = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.deletingLastPathComponent()
let enURL = root.appendingPathComponent("Sources/CodexBar/Resources/en.lproj/Localizable.strings")
let zhURL = root.appendingPathComponent("Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings")
let en = try Self.readStringsTable(at: enURL)
let zh = try Self.readStringsTable(at: zhURL)
for key in Self.localizedKeys {
let enValue = try #require(en[key], "Missing en key: \(key)")
let zhValue = try #require(zh[key], "Missing zh-Hans key: \(key)")
#expect(
Self.placeholderTokens(in: enValue) == Self.placeholderTokens(in: zhValue),
"Placeholder mismatch for key '\(key)': en='\(enValue)' zh='\(zhValue)'")
}
}
private static func readStringsTable(at url: URL) throws -> [String: String] {
guard let dict = NSDictionary(contentsOf: url) as? [String: String] else {
throw NSError(
domain: "UsagePaceTextTests",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Failed to parse strings file at \(url.path)"])
}
return dict
}
private static func placeholderTokens(in value: String) -> [String] {
guard let regex = try? NSRegularExpression(pattern: "%(?:\\d+\\$)?[@dDuUxXfFeEgGcCsSpaA]") else {
return []
}
let nsRange = NSRange(value.startIndex..<value.endIndex, in: value)
return regex
.matches(in: value, options: [], range: nsRange)
.compactMap { Range($0.range, in: value).map { String(value[$0]) } }
}
}