fix(claude): keep at-limit claude-swap cards complete (#3081)

* fix(claude): keep at-limit claude-swap cards complete

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs: cite at-limit claude-swap card PR

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(claude): keep non-limit claude-swap sentinels metrics-less

Project usage only for ok and unavailable slots, and retain a previous snapshot only when the email still matches and a 100% window has not reset.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(claude): drop expired windows from retained at-limit snapshots

Keeping the whole previous snapshot after any sibling reset still showed
"Resets now" on lanes that had already recovered.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(claude): retain at-limit swap windows in CLI and dashboard

One-shot cards and dashboard now reuse the last slot windows so an
unavailable cswap row with null usage keeps the exhausted bars.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(claude): bind retained at-limit windows to the slot account

A SHA-256 fingerprint in the retained-usage cache rejects leftover 100% bars after the same slot is reused by a different account.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(claude): prune expired windows on attached unavailable snapshots

Direct cswap unavailable payloads with mixed reset times now drop already-reset lanes the same way retained snapshots do.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(claude): keep retained at-limit bars on dashboard and app restart

Seed the app projection from the retained-usage cache after a relaunch, and render dashboard windows alongside the deferred/limit note instead of returning after the error.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(claude): require an email before retaining at-limit windows

Slot-only fingerprints reused bars after an email-less account was replaced. Decline persistence and reuse unless the row has an email, and move the changelog note to 0.54.1 Unreleased.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(claude): save retained usage only for current refreshes

Stale claude-swap refreshes were writing the on-disk cache before the generation guard, and 100% windows without a future reset were kept forever.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(ci): flatten fingerprint matching and expect localized API key details

SwiftFormat rejected the nested guard wrap, and OpenRouter detail titles now follow the #3084 localization pass.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(claude): drop retained windows without a future reset

A non-exhausted session lane with no resetsAt was kept beside a weekly at-limit bar and re-saved until that weekly reset, so a five-hour value could linger for days.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
sf-jin-ku
2026-08-19 23:41:50 -07:00
committed by GitHub
parent 61f542253c
commit 6ed9d98e35
15 changed files with 1182 additions and 32 deletions
+1
View File
@@ -14,6 +14,7 @@
- Hide untouched Antigravity model families in the `codexbar serve` web dashboard, matching the menu and widgets (#3061). Thanks @urda!
- Documented the AI Usage Limits Stream Deck plugin in the README integrations list (#3066). Thanks @lenadweb!
- OpenCode Go: use the public authenticated usage API when `OPENCODE_API_KEY` is configured, overlaying authoritative rolling/weekly/monthly windows on local history with cookie fallback (#2879, #3065). Thanks @akshayprabhu200!
- Claude: keep 100% claude-swap usage bars when cswap defers polling at a limit, and name the exhausted window and reset instead of showing "Usage fetch failed." (#3081).
## 0.54.0 — 2026-08-18
@@ -74,10 +74,14 @@ extension UsageStore {
do {
let list = try await ClaudeSwapAccountReader.readAccountList(executablePath: executablePath)
let snapshots = ClaudeSwapAccountProjection.accountSnapshots(from: list)
let snapshots = ClaudeSwapAccountProjection.accountSnapshots(
from: list,
previousAccounts: ClaudeSwapRetainedUsageStore.previousAccounts(
inMemory: self.claudeSwapAccountSnapshots))
guard self.isCurrentClaudeSwapRefresh(executablePath: executablePath, generation: generation) else {
return
}
ClaudeSwapRetainedUsageStore.save(snapshots)
self.claudeSwapAccountSnapshots = snapshots
self.claudeSwapLastRefreshAt = Date()
self.claudeSwapLastError = nil
+8 -2
View File
@@ -125,13 +125,19 @@ enum CLIClaudeSwapCards {
showSingleAccount: Bool = false,
renderOptions: CLIClaudeSwapCardsRenderOptions,
ambientFetch: @escaping AmbientFetch,
accountListReader: @escaping AccountListReader) async -> UsageCommandOutput
accountListReader: @escaping AccountListReader,
previousAccounts: [ProviderAccountUsageSnapshot] = []) async -> UsageCommandOutput
{
guard eligible else { return await ambientFetch() }
do {
let list = try await accountListReader(executablePath)
let accounts = ClaudeSwapAccountProjection.accountSnapshots(from: list, now: renderOptions.now)
let retained = ClaudeSwapRetainedUsageStore.previousAccounts(inMemory: previousAccounts)
let accounts = ClaudeSwapAccountProjection.accountSnapshots(
from: list,
previousAccounts: retained,
now: renderOptions.now)
ClaudeSwapRetainedUsageStore.save(accounts)
guard ClaudeSwapAccountProjection.shouldPresentAccounts(
accountCount: accounts.count,
showSingleAccount: showSingleAccount)
@@ -139,8 +139,12 @@ struct DashboardSnapshotProducer: Sendable {
let list = try await ClaudeSwapAccountReader.readAccountList(
executablePath: path,
timeout: timeout)
let accounts = ClaudeSwapAccountProjection.accountSnapshots(
from: list,
previousAccounts: ClaudeSwapRetainedUsageStore.load())
ClaudeSwapRetainedUsageStore.save(accounts)
return DashboardClaudeSwapCollection(
accounts: ClaudeSwapAccountProjection.accountSnapshots(from: list),
accounts: accounts,
adapterError: nil)
} catch {
let diagnostic = CLIClaudeSwapText.sanitizeDiagnostic(error.localizedDescription)
+2 -1
View File
@@ -899,9 +899,10 @@ extension CLIServeWebUI {
card.append(identity);
}
// At-limit claude-swap cards carry both a deferred/limit note and retained
// windows; keep those bars visible instead of returning after the note.
if (account.error) {
card.append(node("p", "error-message", account.error));
return card;
}
const windows = node("div", "windows");
@@ -8,6 +8,8 @@ public enum ClaudeSwapAccountProjection {
public static let sourceLabel = "claude-swap"
static let fiveHourWindowMinutes = 5 * 60
static let sevenDayWindowMinutes = 7 * 24 * 60
static let exhaustedUsedPercent = 100.0
static let deferredPollingNote = "Polling deferred until a limit resets."
public static func shouldPresentAccounts(accountCount: Int, showSingleAccount: Bool) -> Bool {
accountCount >= (showSingleAccount ? 1 : 2)
@@ -15,8 +17,12 @@ public enum ClaudeSwapAccountProjection {
public static func accountSnapshots(
from list: ClaudeSwapAccountList,
previousAccounts: [ProviderAccountUsageSnapshot] = [],
now: Date = Date()) -> [ProviderAccountUsageSnapshot]
{
let previousByID = Dictionary(
previousAccounts.map { ($0.id, $0) },
uniquingKeysWith: { first, _ in first })
let ordered = list.accounts.sorted { lhs, rhs in
if lhs.isActive != rhs.isActive {
return lhs.isActive
@@ -24,14 +30,19 @@ public enum ClaudeSwapAccountProjection {
return lhs.number < rhs.number
}
return ordered.map { row in
ProviderAccountUsageSnapshot(
id: ProviderAccountIdentity(source: self.sourceName, opaqueID: String(row.number)),
let id = ProviderAccountIdentity(source: self.sourceName, opaqueID: String(row.number))
let snapshot = self.usageSnapshot(
for: row,
previous: previousByID[id],
now: now)
return ProviderAccountUsageSnapshot(
id: id,
provider: .claude,
displayLabel: self.displayLabel(for: row),
isActive: row.isActive,
canActivate: !row.isActive && self.canActivate(row),
snapshot: self.usageSnapshot(for: row, now: now),
error: self.errorText(for: row),
snapshot: snapshot,
error: self.errorText(for: row, snapshot: snapshot, now: now),
sourceLabel: self.sourceLabel)
}
}
@@ -50,8 +61,85 @@ public enum ClaudeSwapAccountProjection {
row.email.isEmpty ? "Account \(row.number)" : row.email
}
private static func usageSnapshot(for row: ClaudeSwapAccountRow, now: Date) -> UsageSnapshot? {
guard row.usageStatus == .ok else { return nil }
private static func usageSnapshot(
for row: ClaudeSwapAccountRow,
previous: ProviderAccountUsageSnapshot?,
now: Date) -> UsageSnapshot?
{
switch row.usageStatus {
case .ok, .unavailable:
if let projected = self.projectedUsageSnapshot(for: row, now: now) {
if row.usageStatus == .ok {
return projected
}
if let pruned = self.prunedAtLimitSnapshot(
projected,
identity: projected.identity ?? self.identitySnapshot(for: row),
now: now)
{
return pruned
}
}
guard row.usageStatus == .unavailable else { return nil }
return self.retainedAtLimitSnapshot(previous, matching: row, now: now)
case .tokenExpired, .reloginRequired, .apiKey, .keychainUnavailable, .noCredentials, .unknown:
return nil
}
}
private static func retainedAtLimitSnapshot(
_ previous: ProviderAccountUsageSnapshot?,
matching row: ClaudeSwapAccountRow,
now: Date) -> UsageSnapshot?
{
guard let previous, let snapshot = previous.snapshot else { return nil }
let previousFingerprint = ClaudeSwapRetainedUsageStore.fingerprint(from: previous)
let rowFingerprint = ClaudeSwapRetainedUsageStore.fingerprint(
email: row.email,
slot: String(row.number))
guard let previousFingerprint, let rowFingerprint, previousFingerprint == rowFingerprint else {
return nil
}
return self.prunedAtLimitSnapshot(snapshot, identity: self.identitySnapshot(for: row), now: now)
}
/// Drops windows whose reset is in the past so a mixed snapshot cannot keep showing
/// an already-reset lane as "Resets now" just because a sibling is still exhausted.
private static func prunedAtLimitSnapshot(
_ snapshot: UsageSnapshot,
identity: ProviderIdentitySnapshot?,
now: Date) -> UsageSnapshot?
{
let primary = self.unexpiredWindow(snapshot.primary, now: now)
let secondary = self.unexpiredWindow(snapshot.secondary, now: now)
let extra = (snapshot.extraRateWindows ?? []).compactMap { named -> NamedRateWindow? in
guard let window = self.unexpiredWindow(named.window, now: now) else { return nil }
return NamedRateWindow(
id: named.id,
title: named.title,
window: window,
usageKnown: named.usageKnown)
}
let remaining = [primary, secondary].compactMap(\.self) + extra.map(\.window)
guard remaining.contains(where: { $0.usedPercent >= self.exhaustedUsedPercent }) else {
return nil
}
return UsageSnapshot(
primary: primary,
secondary: secondary,
extraRateWindows: extra.isEmpty ? nil : extra,
updatedAt: snapshot.updatedAt,
identity: identity,
dataConfidence: snapshot.dataConfidence)
}
private static func unexpiredWindow(_ window: RateWindow?, now: Date) -> RateWindow? {
guard let window else { return nil }
guard let resetsAt = window.resetsAt, resetsAt > now else { return nil }
return window
}
private static func projectedUsageSnapshot(for row: ClaudeSwapAccountRow, now: Date) -> UsageSnapshot? {
let primary = row.fiveHour.map { window in
RateWindow(
usedPercent: window.usedPercent,
@@ -73,11 +161,15 @@ public enum ClaudeSwapAccountProjection {
secondary: secondary,
extraRateWindows: scoped.isEmpty ? nil : scoped,
updatedAt: now,
identity: ProviderIdentitySnapshot(
providerID: .claude,
accountEmail: self.displayLabel(for: row),
accountOrganization: nil,
loginMethod: self.sourceLabel))
identity: self.identitySnapshot(for: row))
}
private static func identitySnapshot(for row: ClaudeSwapAccountRow) -> ProviderIdentitySnapshot {
ProviderIdentitySnapshot(
providerID: .claude,
accountEmail: self.displayLabel(for: row),
accountOrganization: nil,
loginMethod: self.sourceLabel)
}
private static func scopedRateWindows(for row: ClaudeSwapAccountRow) -> [NamedRateWindow] {
@@ -92,12 +184,10 @@ public enum ClaudeSwapAccountProjection {
})
}
private static func errorText(for row: ClaudeSwapAccountRow) -> String? {
private static func errorText(for row: ClaudeSwapAccountRow, snapshot: UsageSnapshot?, now: Date) -> String? {
switch row.usageStatus {
case .ok:
row.fiveHour == nil && row.sevenDay == nil && self.scopedRateWindows(for: row).isEmpty
? "No usage windows reported."
: nil
snapshot == nil ? "No usage windows reported." : nil
case .tokenExpired:
"Token expired. Switch to this account in claude-swap to refresh it."
case .reloginRequired:
@@ -109,12 +199,48 @@ public enum ClaudeSwapAccountProjection {
case .noCredentials:
"No stored credentials for this account slot."
case .unavailable:
"Usage fetch failed."
self.atLimitNote(from: snapshot, now: now) ?? self.deferredPollingNote
case let .unknown(raw):
"Unrecognized claude-swap status: \(raw)"
}
}
private static func atLimitNote(from snapshot: UsageSnapshot?, now: Date) -> String? {
guard let snapshot else { return nil }
var parts: [String] = []
if let primary = snapshot.primary {
self.appendLimit(named: "Session", window: primary, now: now, to: &parts)
}
if let secondary = snapshot.secondary {
self.appendLimit(named: "Weekly", window: secondary, now: now, to: &parts)
}
for extra in snapshot.extraRateWindows ?? [] {
self.appendLimit(named: self.scopedLimitName(extra.title), window: extra.window, now: now, to: &parts)
}
guard !parts.isEmpty else { return nil }
return parts.joined(separator: " ")
}
private static func appendLimit(
named name: String,
window: RateWindow,
now: Date,
to parts: inout [String])
{
guard window.usedPercent >= self.exhaustedUsedPercent else { return }
if let reset = UsageFormatter.resetLine(for: window, style: .countdown, now: now) {
parts.append("\(name) limit reached. \(reset).")
} else {
parts.append("\(name) limit reached.")
}
}
private static func scopedLimitName(_ title: String) -> String {
let suffix = " only"
guard title.hasSuffix(suffix) else { return title }
return String(title.dropLast(suffix.count))
}
private static func canActivate(_ row: ClaudeSwapAccountRow) -> Bool {
switch row.usageStatus {
case .ok, .apiKey, .unavailable:
@@ -0,0 +1,141 @@
#if canImport(CryptoKit)
import CryptoKit
#else
import Crypto
#endif
import Foundation
/// Slot-keyed usage windows from the last successful Claude Swap projection.
/// Display labels and emails stay out of the cache so one-shot CLI/dashboard
/// calls can retain at-limit bars without persisting identity. A SHA-256
/// fingerprint binds those windows to the account that produced them.
public enum ClaudeSwapRetainedUsageStore {
private static let fingerprintPrefix = "fp:"
public static func load() -> [ProviderAccountUsageSnapshot] {
guard let url = self.resolvedFileURL(),
let data = try? Data(contentsOf: url),
let records = try? JSONDecoder().decode([Record].self, from: data)
else { return [] }
return records.map(\.account)
}
/// After a relaunch the in-memory array is empty even when this cache still
/// holds complete windows, so fall back to disk only when nothing is in memory.
public static func previousAccounts(
inMemory: [ProviderAccountUsageSnapshot]) -> [ProviderAccountUsageSnapshot]
{
inMemory.isEmpty ? self.load() : inMemory
}
public static func save(_ accounts: [ProviderAccountUsageSnapshot]) {
guard let url = self.resolvedFileURL() else { return }
let records = accounts.compactMap(Record.init(account:))
guard let data = try? JSONEncoder().encode(records) else { return }
try? FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try? data.write(to: url, options: .atomic)
}
/// In-memory equivalent of save/load, used to prove cache-shaped previous snapshots
/// still reject a different account in the same slot.
static func snapshotsForRetention(
_ accounts: [ProviderAccountUsageSnapshot]) -> [ProviderAccountUsageSnapshot]
{
accounts.compactMap(Record.init(account:)).map(\.account)
}
static func fingerprint(email: String, slot: String) -> String? {
let trimmed = email.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
guard trimmed.contains("@") else { return nil }
let material = "\(slot)\u{0}\(trimmed)"
return SHA256.hash(data: Data(material.utf8)).map { String(format: "%02x", $0) }.joined()
}
static func fingerprint(from account: ProviderAccountUsageSnapshot) -> String? {
if let stored = account.snapshot?.identity?.accountID,
stored.hasPrefix(self.fingerprintPrefix)
{
return String(stored.dropFirst(self.fingerprintPrefix.count))
}
let email = account.snapshot?.identity?.accountEmail ?? account.displayLabel
if email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
return nil
}
return self.fingerprint(email: email, slot: account.id.opaqueID)
}
static func fingerprintAccountID(_ fingerprint: String) -> String {
self.fingerprintPrefix + fingerprint
}
private static func resolvedFileURL() -> URL? {
if self.isRunningTests { return nil }
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
return base?
.appendingPathComponent("CodexBar", isDirectory: true)
.appendingPathComponent("claude-swap-retained-usage.json")
}
private static var isRunningTests: Bool {
let environment = ProcessInfo.processInfo.environment
if environment["XCTestConfigurationFilePath"] != nil || environment["XCTestBundlePath"] != nil {
return true
}
if ProcessInfo.processInfo.processName.lowercased().contains("xctest") {
return true
}
return CommandLine.arguments.contains { $0.lowercased().contains(".xctest") }
}
private struct Record: Codable {
var opaqueID: String
var accountFingerprint: String
var primary: RateWindow?
var secondary: RateWindow?
var extraRateWindows: [NamedRateWindow]?
var updatedAt: Date
init?(account: ProviderAccountUsageSnapshot) {
guard account.id.source == ClaudeSwapAccountProjection.sourceName,
let snapshot = account.snapshot
else { return nil }
self.opaqueID = account.id.opaqueID
guard let fingerprint = ClaudeSwapRetainedUsageStore.fingerprint(
email: snapshot.identity?.accountEmail ?? account.displayLabel,
slot: account.id.opaqueID)
else {
return nil
}
self.accountFingerprint = fingerprint
self.primary = snapshot.primary
self.secondary = snapshot.secondary
self.extraRateWindows = snapshot.extraRateWindows
self.updatedAt = snapshot.updatedAt
}
var account: ProviderAccountUsageSnapshot {
ProviderAccountUsageSnapshot(
id: ProviderAccountIdentity(
source: ClaudeSwapAccountProjection.sourceName,
opaqueID: self.opaqueID),
provider: .claude,
displayLabel: "",
isActive: false,
snapshot: UsageSnapshot(
primary: self.primary,
secondary: self.secondary,
extraRateWindows: self.extraRateWindows,
updatedAt: self.updatedAt,
identity: ProviderIdentitySnapshot(
providerID: .claude,
accountEmail: nil,
accountOrganization: nil,
loginMethod: ClaudeSwapAccountProjection.sourceLabel,
accountID: ClaudeSwapRetainedUsageStore.fingerprintAccountID(self.accountFingerprint))),
error: nil,
sourceLabel: ClaudeSwapAccountProjection.sourceLabel)
}
}
}
@@ -271,7 +271,7 @@ struct CLICardsClaudeSwapTests {
"Re-login required. Re-authenticate this account in claude-swap.",
"claude-swap could not read the active account's Keychain entry.",
"No stored credentials for this account slot.",
"Usage fetch failed.",
"Polling deferred until a limit resets.",
"Unrecognized claude-swap status: future_status",
"No usage windows reported.",
])
@@ -279,7 +279,7 @@ struct CLICardsClaudeSwapTests {
@Test
func `active sentinel account remains active and metrics less in full and brief cards`() async {
let problem = "Usage fetch failed."
let problem = "Polling deferred until a limit resets."
let output = await CLIClaudeSwapCards.fetch(
eligible: true,
executablePath: "/fake/cswap",
@@ -315,6 +315,83 @@ struct CLICardsClaudeSwapTests {
#expect(rows.first?.usedPercent == nil)
}
@Test
func `unavailable at limit windows keep metrics and name the exhausted window`() async {
let reset = Date(timeIntervalSince1970: 1_700_003_600)
let output = await CLIClaudeSwapCards.fetch(
eligible: true,
executablePath: "/fake/cswap",
renderOptions: self.renderOptions(),
ambientFetch: { self.ambientOutput(failed: true) },
accountListReader: { _ in
ClaudeSwapAccountList(activeAccountNumber: 1, accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "limited@example.com",
isActive: true,
usageStatus: .unavailable,
fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset),
sevenDay: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset)),
self.row(number: 2),
])
})
#expect(output.exitCode == .success)
let activeCard = output.cards.first
#expect(activeCard?.accountLine == "limited@example.com")
#expect(activeCard?.isActive == true)
#expect(activeCard?.accountProblem ==
"Session limit reached. Resets in 1h. Weekly limit reached. Resets in 1h.")
#expect(activeCard?.metrics.isEmpty == false)
#expect(activeCard?.metrics.contains { $0.remainingPercent == 0 } == true)
#expect(activeCard?.accountProblem?.contains("Usage fetch failed") != true)
let rows = CLICardsBriefRenderer.makeRows(cards: activeCard.map { [$0] } ?? [])
#expect(rows.first?.accountProblem?.contains("Session limit reached") == true)
#expect(rows.first?.usedPercent == 100)
}
@Test
func `unavailable null usage retains previous CLI windows`() async {
let reset = Date(timeIntervalSince1970: 1_700_003_600)
let previous = ClaudeSwapAccountProjection.accountSnapshots(
from: ClaudeSwapAccountList(activeAccountNumber: 1, accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "limited@example.com",
isActive: true,
usageStatus: .ok,
fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset),
sevenDay: ClaudeSwapUsageWindow(usedPercent: 40, resetsAt: reset)),
]),
now: Date(timeIntervalSince1970: 1_700_000_000))
let output = await CLIClaudeSwapCards.fetch(
eligible: true,
executablePath: "/fake/cswap",
renderOptions: self.renderOptions(),
ambientFetch: { self.ambientOutput(failed: true) },
accountListReader: { _ in
ClaudeSwapAccountList(activeAccountNumber: 1, accounts: [
self.row(
number: 1,
active: true,
status: .unavailable,
email: "limited@example.com",
hasUsage: false),
self.row(number: 2),
])
},
previousAccounts: previous)
#expect(output.exitCode == .success)
let activeCard = output.cards.first
#expect(activeCard?.accountLine == "limited@example.com")
#expect(activeCard?.metrics.isEmpty == false)
#expect(activeCard?.metrics.contains { $0.remainingPercent == 0 } == true)
#expect(activeCard?.accountProblem?.contains("Session limit reached") == true)
#expect(activeCard?.accountProblem?.contains("Usage fetch failed") != true)
}
@Test
func `blank executable path preserves ambient output and fails distinctly`() async {
let ambient = self.ambientOutput()
@@ -30,6 +30,16 @@ struct CLIServeWebUITests {
#expect(CLIServeWebUI.iconResponse(name: "../etc/passwd") == nil)
}
@Test
func `web ui renders account windows alongside an error note`() {
let html = self.html
let errorAppend = "card.append(node(\"p\", \"error-message\", account.error));"
#expect(html.contains(errorAppend))
#expect(!html.contains(errorAppend + "\n return card;"))
#expect(html.contains(
"for (const window of visibleWindows(account.windows)) windows.append(renderWindow(window))"))
}
@Test
func `web ui skips windows the snapshot marks idle`() {
let html = self.html
@@ -177,6 +177,48 @@ struct ClaudeProviderRuntimeTests {
#expect(store.claudeSwapTransientState.lastErrorAccountID == nil)
}
@Test
func `unavailable refresh retains previous at limit snapshot`() async throws {
let (settings, store) = self.makeStore()
let executable = try self.makeUnavailableListExecutable()
let metadata = try #require(ProviderRegistry.shared.metadata[.claude])
settings.setProviderEnabled(provider: .claude, metadata: metadata, enabled: true)
settings.claudeSwapExecutablePath = executable
settings.claudeSwapEnabled = true
let now = Date()
let previous = ClaudeSwapAccountProjection.accountSnapshots(
from: ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "a@b.c",
isActive: true,
usageStatus: .ok,
fiveHour: ClaudeSwapUsageWindow(
usedPercent: 100,
resetsAt: now.addingTimeInterval(3600)),
sevenDay: ClaudeSwapUsageWindow(
usedPercent: 100,
resetsAt: now.addingTimeInterval(86400))),
]),
now: now)
store.claudeSwapAccountSnapshots = previous
await store.refreshClaudeSwapAccounts()
let account = try #require(store.claudeSwapAccountSnapshots.first)
#expect(account.id == ProviderAccountIdentity(source: "claude-swap", opaqueID: "1"))
#expect(account.snapshot?.primary?.usedPercent == 100)
#expect(account.snapshot?.secondary?.usedPercent == 100)
#expect(account.snapshot?.updatedAt == now)
let error = try #require(account.error)
#expect(error.contains("Session limit reached"))
#expect(error.contains("Weekly limit reached"))
#expect(!error.contains("Usage fetch failed"))
#expect(store.claudeSwapLastError == nil)
}
private func makeStore() -> (SettingsStore, UsageStore) {
let suite = "ClaudeProviderRuntimeTests-\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
@@ -242,6 +284,28 @@ struct ClaudeProviderRuntimeTests {
return url.path
}
private func makeUnavailableListExecutable() throws -> String {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("claude-unavailable-runtime-tests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appendingPathComponent("cswap")
let script = """
#!/bin/sh
if [ "$1" = "--version" ]; then
echo 'cswap 0.22.0'
exit 0
fi
cat <<'EOF'
{"schemaVersion":1,"activeAccountNumber":1,"accounts":[
{"number":1,"email":"a@b.c","active":true,"usageStatus":"unavailable","usage":null}
]}
EOF
"""
try script.write(to: url, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: url.path)
return url.path
}
private func makeFailedSwitchExecutable() throws -> String {
let directory = FileManager.default.temporaryDirectory
.appendingPathComponent("claude-failed-switch-runtime-tests-\(UUID().uuidString)", isDirectory: true)
@@ -80,7 +80,6 @@ struct ClaudeSwapAccountProjectionTests {
(.apiKey, "API-key account"),
(.keychainUnavailable, "Keychain"),
(.noCredentials, "No stored credentials"),
(.unavailable, "Usage fetch failed"),
(.unknown("mystery"), "mystery"),
]
@@ -101,11 +100,448 @@ struct ClaudeSwapAccountProjectionTests {
#expect(snapshot.snapshot == nil)
let error = try #require(snapshot.error)
#expect(error.contains(entry.1))
let expectedCanActivate = entry.0 == .apiKey || entry.0 == .unavailable
#expect(snapshot.canActivate == expectedCanActivate)
#expect(snapshot.canActivate == (entry.0 == .apiKey))
}
}
@Test
func `unavailable without windows or prior snapshot reports deferred polling`() throws {
let list = ClaudeSwapAccountList(
activeAccountNumber: nil,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "a@b.c",
isActive: false,
usageStatus: .unavailable,
fiveHour: nil,
sevenDay: nil),
])
let snapshot = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now).first)
#expect(snapshot.snapshot == nil)
#expect(snapshot.error == "Polling deferred until a limit resets.")
#expect(snapshot.canActivate == true)
#expect(snapshot.error?.contains("Usage fetch failed") != true)
}
@Test
func `projects usage windows even when status is unavailable`() throws {
let reset = Date(timeIntervalSince1970: 1_782_003_600)
let list = ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "a@b.c",
isActive: true,
usageStatus: .unavailable,
fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset),
sevenDay: ClaudeSwapUsageWindow(usedPercent: 42, resetsAt: nil)),
])
let account = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now).first)
let snapshot = try #require(account.snapshot)
#expect(snapshot.primary?.usedPercent == 100)
#expect(snapshot.secondary == nil)
#expect(account.error == "Session limit reached. Resets in 1h.")
#expect(account.error?.contains("Usage fetch failed") != true)
}
@Test
func `unavailable attached windows drop expired lanes and keep remaining at limit`() throws {
let list = ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "a@b.c",
isActive: true,
usageStatus: .unavailable,
fiveHour: ClaudeSwapUsageWindow(
usedPercent: 100,
resetsAt: self.now.addingTimeInterval(-60)),
sevenDay: ClaudeSwapUsageWindow(
usedPercent: 100,
resetsAt: self.now.addingTimeInterval(86400))),
])
let account = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now).first)
#expect(account.snapshot?.primary == nil)
#expect(account.snapshot?.secondary?.usedPercent == 100)
let error = try #require(account.error)
#expect(error.contains("Weekly limit reached"))
#expect(!error.contains("Session limit reached"))
#expect(!error.contains("Resets now"))
}
@Test
func `names each exhausted window including scoped models`() throws {
let sessionReset = Date(timeIntervalSince1970: 1_782_003_600)
let weeklyReset = Date(timeIntervalSince1970: 1_782_259_200)
let list = ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "a@b.c",
isActive: true,
usageStatus: .unavailable,
fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: sessionReset),
sevenDay: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: weeklyReset),
scoped: [
ClaudeSwapScopedUsageWindow(name: "Fable", usedPercent: 100, resetsAt: weeklyReset),
]),
])
let account = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now).first)
#expect(account.snapshot?.primary?.usedPercent == 100)
#expect(account.snapshot?.secondary?.usedPercent == 100)
#expect(account.snapshot?.extraRateWindows?.first?.window.usedPercent == 100)
#expect(account.error == [
"Session limit reached. Resets in 1h.",
"Weekly limit reached. Resets in 3d.",
"Fable limit reached. Resets in 3d.",
].joined(separator: " "))
}
@Test
func `unavailable without windows retains previous snapshot as current at limit usage`() throws {
let reset = Date(timeIntervalSince1970: 1_782_259_200)
let previousList = ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "a@b.c",
isActive: true,
usageStatus: .ok,
fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset),
sevenDay: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset)),
])
let previous = ClaudeSwapAccountProjection.accountSnapshots(from: previousList, now: self.now)
let list = ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "a@b.c",
isActive: true,
usageStatus: .unavailable,
fiveHour: nil,
sevenDay: nil),
])
let account = try #require(
ClaudeSwapAccountProjection.accountSnapshots(
from: list,
previousAccounts: previous,
now: self.now.addingTimeInterval(3600)).first)
#expect(account.id == ProviderAccountIdentity(source: "claude-swap", opaqueID: "1"))
#expect(account.snapshot?.primary?.usedPercent == 100)
#expect(account.snapshot?.secondary?.usedPercent == 100)
#expect(account.snapshot?.updatedAt == self.now)
let error = try #require(account.error)
#expect(error.contains("Session limit reached"))
#expect(error.contains("Weekly limit reached"))
#expect(!error.contains("Usage fetch failed"))
#expect(!error.contains("last successful update"))
}
@Test
func `unavailable retain drops expired windows and keeps remaining at limit lanes`() throws {
let previous = ClaudeSwapAccountProjection.accountSnapshots(
from: ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "a@b.c",
isActive: true,
usageStatus: .ok,
fiveHour: ClaudeSwapUsageWindow(
usedPercent: 100,
resetsAt: self.now.addingTimeInterval(-60)),
sevenDay: ClaudeSwapUsageWindow(
usedPercent: 100,
resetsAt: self.now.addingTimeInterval(86400))),
]),
now: self.now)
let list = ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "a@b.c",
isActive: true,
usageStatus: .unavailable,
fiveHour: nil,
sevenDay: nil),
])
let account = try #require(
ClaudeSwapAccountProjection.accountSnapshots(
from: list,
previousAccounts: previous,
now: self.now).first)
#expect(account.snapshot?.primary == nil)
#expect(account.snapshot?.secondary?.usedPercent == 100)
let error = try #require(account.error)
#expect(error.contains("Weekly limit reached"))
#expect(!error.contains("Session limit reached"))
#expect(!error.contains("Resets now"))
}
@Test
func `unavailable retain drops a snapshot whose at limit windows have all reset`() throws {
let previous = ClaudeSwapAccountProjection.accountSnapshots(
from: ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "a@b.c",
isActive: true,
usageStatus: .ok,
fiveHour: ClaudeSwapUsageWindow(
usedPercent: 100,
resetsAt: self.now.addingTimeInterval(-3600)),
sevenDay: ClaudeSwapUsageWindow(
usedPercent: 100,
resetsAt: self.now.addingTimeInterval(-60))),
]),
now: self.now)
let list = ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "a@b.c",
isActive: true,
usageStatus: .unavailable,
fiveHour: nil,
sevenDay: nil),
])
let account = try #require(
ClaudeSwapAccountProjection.accountSnapshots(
from: list,
previousAccounts: previous,
now: self.now).first)
#expect(account.snapshot == nil)
#expect(account.error == "Polling deferred until a limit resets.")
}
@Test
func `unavailable retain drops exhausted windows without a reset timestamp`() throws {
let previous = ClaudeSwapAccountProjection.accountSnapshots(
from: ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "a@b.c",
isActive: true,
usageStatus: .ok,
fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: nil),
sevenDay: ClaudeSwapUsageWindow(
usedPercent: 100,
resetsAt: self.now.addingTimeInterval(86400))),
]),
now: self.now)
let list = ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "a@b.c",
isActive: true,
usageStatus: .unavailable,
fiveHour: nil,
sevenDay: nil),
])
let account = try #require(
ClaudeSwapAccountProjection.accountSnapshots(
from: list,
previousAccounts: previous,
now: self.now).first)
#expect(account.snapshot?.primary == nil)
#expect(account.snapshot?.secondary?.usedPercent == 100)
let error = try #require(account.error)
#expect(error.contains("Weekly limit reached"))
#expect(!error.contains("Session limit reached"))
}
@Test
func `unavailable retain drops unknown reset lanes that are not exhausted`() throws {
let previous = ClaudeSwapAccountProjection.accountSnapshots(
from: ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "a@b.c",
isActive: true,
usageStatus: .ok,
fiveHour: ClaudeSwapUsageWindow(usedPercent: 40, resetsAt: nil),
sevenDay: ClaudeSwapUsageWindow(
usedPercent: 100,
resetsAt: self.now.addingTimeInterval(86400))),
]),
now: self.now)
let list = ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "a@b.c",
isActive: true,
usageStatus: .unavailable,
fiveHour: nil,
sevenDay: nil),
])
let account = try #require(
ClaudeSwapAccountProjection.accountSnapshots(
from: list,
previousAccounts: previous,
now: self.now).first)
#expect(account.snapshot?.primary == nil)
#expect(account.snapshot?.secondary?.usedPercent == 100)
let error = try #require(account.error)
#expect(error.contains("Weekly limit reached"))
#expect(!error.contains("Session limit reached"))
}
@Test
func `token expired does not retain a previous usage snapshot`() throws {
let previousList = ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "a@b.c",
isActive: true,
usageStatus: .ok,
fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: nil),
sevenDay: nil),
])
let previous = ClaudeSwapAccountProjection.accountSnapshots(from: previousList, now: self.now)
let list = ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "a@b.c",
isActive: true,
usageStatus: .tokenExpired,
fiveHour: nil,
sevenDay: nil),
])
let account = try #require(
ClaudeSwapAccountProjection.accountSnapshots(
from: list,
previousAccounts: previous,
now: self.now).first)
#expect(account.snapshot == nil)
#expect(account.error?.contains("Token expired") == true)
}
@Test
func `token expired with cached windows stays metrics less`() throws {
let list = ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "a@b.c",
isActive: true,
usageStatus: .tokenExpired,
fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: nil),
sevenDay: ClaudeSwapUsageWindow(usedPercent: 80, resetsAt: nil)),
])
let account = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now).first)
#expect(account.snapshot == nil)
#expect(account.error?.contains("Token expired") == true)
}
@Test
func `unavailable does not reuse a previous snapshot from a different email`() throws {
let reset = Date(timeIntervalSince1970: 1_782_259_200)
let previous = ClaudeSwapAccountProjection.accountSnapshots(
from: ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "old@example.com",
isActive: true,
usageStatus: .ok,
fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset),
sevenDay: nil),
]),
now: self.now)
let list = ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "new@example.com",
isActive: true,
usageStatus: .unavailable,
fiveHour: nil,
sevenDay: nil),
])
let account = try #require(
ClaudeSwapAccountProjection.accountSnapshots(
from: list,
previousAccounts: previous,
now: self.now).first)
#expect(account.displayLabel == "new@example.com")
#expect(account.snapshot == nil)
#expect(account.error == "Polling deferred until a limit resets.")
}
@Test
func `unavailable does not retain a previous snapshot that is not at a limit`() throws {
let previous = ClaudeSwapAccountProjection.accountSnapshots(
from: ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "a@b.c",
isActive: true,
usageStatus: .ok,
fiveHour: ClaudeSwapUsageWindow(usedPercent: 20, resetsAt: nil),
sevenDay: nil),
]),
now: self.now)
let list = ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "a@b.c",
isActive: true,
usageStatus: .unavailable,
fiveHour: nil,
sevenDay: nil),
])
let account = try #require(
ClaudeSwapAccountProjection.accountSnapshots(
from: list,
previousAccounts: previous,
now: self.now).first)
#expect(account.snapshot == nil)
#expect(account.error == "Polling deferred until a limit resets.")
}
@Test
func `ok row without windows reports missing usage instead of an empty card`() throws {
let list = ClaudeSwapAccountList(
@@ -197,4 +633,195 @@ struct ClaudeSwapAccountProjectionTests {
let snapshot = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: self.now).first)
#expect(snapshot.displayLabel == "Account 3")
}
@Test
func `unavailable retain ignores cached windows after the slot account changes`() throws {
let reset = Date(timeIntervalSince1970: 1_782_259_200)
let previous = ClaudeSwapAccountProjection.accountSnapshots(
from: ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "old@example.com",
isActive: true,
usageStatus: .ok,
fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset),
sevenDay: nil),
]),
now: self.now)
let cached = ClaudeSwapRetainedUsageStore.snapshotsForRetention(previous)
let list = ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "new@example.com",
isActive: true,
usageStatus: .unavailable,
fiveHour: nil,
sevenDay: nil),
])
let account = try #require(
ClaudeSwapAccountProjection.accountSnapshots(
from: list,
previousAccounts: cached,
now: self.now).first)
#expect(account.displayLabel == "new@example.com")
#expect(account.snapshot == nil)
#expect(account.error == "Polling deferred until a limit resets.")
}
@Test
func `unavailable retain ignores cached windows when the slot has no email`() throws {
let reset = Date(timeIntervalSince1970: 1_782_259_200)
let previous = ClaudeSwapAccountProjection.accountSnapshots(
from: ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "",
isActive: true,
usageStatus: .ok,
fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset),
sevenDay: nil),
]),
now: self.now)
let cached = ClaudeSwapRetainedUsageStore.snapshotsForRetention(previous)
#expect(cached.isEmpty)
let list = ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "",
isActive: true,
usageStatus: .unavailable,
fiveHour: nil,
sevenDay: nil),
])
let account = try #require(
ClaudeSwapAccountProjection.accountSnapshots(
from: list,
previousAccounts: previous,
now: self.now).first)
#expect(account.displayLabel == "Account 1")
#expect(account.snapshot == nil)
#expect(account.error == "Polling deferred until a limit resets.")
}
@Test
func `unavailable retain keeps cached windows for the same slot account`() throws {
let reset = Date(timeIntervalSince1970: 1_782_259_200)
let previous = ClaudeSwapAccountProjection.accountSnapshots(
from: ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "same@example.com",
isActive: true,
usageStatus: .ok,
fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset),
sevenDay: nil),
]),
now: self.now)
let cached = ClaudeSwapRetainedUsageStore.snapshotsForRetention(previous)
let list = ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "same@example.com",
isActive: true,
usageStatus: .unavailable,
fiveHour: nil,
sevenDay: nil),
])
let account = try #require(
ClaudeSwapAccountProjection.accountSnapshots(
from: list,
previousAccounts: cached,
now: self.now).first)
#expect(account.snapshot?.primary?.usedPercent == 100)
#expect(account.snapshot?.identity?.accountEmail == "same@example.com")
#expect(account.error?.contains("Session limit reached") == true)
}
@Test
func `unavailable retain ignores a cache entry with no account discriminator`() throws {
let reset = Date(timeIntervalSince1970: 1_782_259_200)
let previous = ClaudeSwapAccountProjection.accountSnapshots(
from: ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "old@example.com",
isActive: true,
usageStatus: .ok,
fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset),
sevenDay: nil),
]),
now: self.now)
let stripped = previous.map { account in
ProviderAccountUsageSnapshot(
id: account.id,
provider: account.provider,
displayLabel: "",
isActive: account.isActive,
snapshot: account.snapshot.map { snapshot in
UsageSnapshot(
primary: snapshot.primary,
secondary: snapshot.secondary,
extraRateWindows: snapshot.extraRateWindows,
updatedAt: snapshot.updatedAt,
identity: nil)
},
error: nil,
sourceLabel: account.sourceLabel)
}
let list = ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "old@example.com",
isActive: true,
usageStatus: .unavailable,
fiveHour: nil,
sevenDay: nil),
])
let account = try #require(
ClaudeSwapAccountProjection.accountSnapshots(
from: list,
previousAccounts: stripped,
now: self.now).first)
#expect(account.snapshot == nil)
#expect(account.error == "Polling deferred until a limit resets.")
}
@Test
func `previous accounts prefer in-memory snapshots over an empty cache load`() {
let previous = ClaudeSwapAccountProjection.accountSnapshots(
from: ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "work@example.com",
isActive: true,
usageStatus: .ok,
fiveHour: ClaudeSwapUsageWindow(usedPercent: 40, resetsAt: nil),
sevenDay: nil),
]),
now: self.now)
#expect(ClaudeSwapRetainedUsageStore.previousAccounts(inMemory: previous).count == previous.count)
#expect(ClaudeSwapRetainedUsageStore.previousAccounts(inMemory: []).isEmpty)
}
}
@@ -110,4 +110,53 @@ struct MenuCardClaudeSwapAccountTests {
#expect(!model.email.contains("personal@example.com"))
#expect(!model.email.contains("example.com"))
}
@Test
func `at limit unavailable card keeps usage bars and names the exhausted window`() throws {
let now = Date(timeIntervalSince1970: 1_782_000_000)
let metadata = try #require(ProviderDefaults.metadata[.claude])
let list = ClaudeSwapAccountList(
activeAccountNumber: 1,
accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "limited@example.com",
isActive: true,
usageStatus: .unavailable,
fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: now.addingTimeInterval(3600)),
sevenDay: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: now.addingTimeInterval(86400))),
])
let account = try #require(ClaudeSwapAccountProjection.accountSnapshots(from: list, now: now).first)
let snapshot = try #require(account.snapshot)
let model = UsageMenuCardView.Model.make(.init(
provider: .claude,
metadata: metadata,
snapshot: snapshot,
credits: nil,
creditsError: nil,
dashboard: nil,
dashboardError: nil,
tokenSnapshot: nil,
tokenError: nil,
account: AccountInfo(email: account.displayLabel, plan: nil),
isRefreshing: false,
lastError: account.error,
usageBarsShowUsed: true,
resetTimeDisplayStyle: .countdown,
tokenCostUsageEnabled: false,
showOptionalCreditsAndExtraUsage: false,
hidePersonalInfo: false,
now: now))
#expect(model.email == "limited@example.com")
let primary = try #require(model.metrics.first(where: { $0.id == "primary" }))
#expect(primary.percent == 100)
let secondary = try #require(model.metrics.first(where: { $0.id == "secondary" }))
#expect(secondary.percent == 100)
#expect(model.subtitleText ==
"Session limit reached. Resets in 1h. Weekly limit reached. Resets in 1d.")
#expect(model.subtitleStyle == .error)
#expect(!model.subtitleText.contains("Usage fetch failed"))
}
}
@@ -88,10 +88,10 @@ struct PopupLocalizationTests {
now: now))
#expect(model.metrics.first?.title == "額度")
let apiKey = try #require(model.providerDetails.first { $0.title == "API key" })
let apiKey = try #require(model.providerDetails.first { $0.title == "API 金鑰" })
#expect(apiKey.rows.map(\.label) == [
"API key budget", "API key remaining", "API key used", "Reset window",
"Today", "This week", "This month", "Rate limit",
"今天", "本週", "本月", "Rate limit",
])
#expect(apiKey.chart?.points.map(\.label) == ["Today", "This week", "This month"])
#expect(apiKey.rows.last?.value == "100 requests / 10s")
+38 -2
View File
@@ -269,7 +269,7 @@ struct CLICardsClaudeSwapTests {
"Token expired. Switch to this account in claude-swap to refresh it.",
"claude-swap could not read the active account's Keychain entry.",
"No stored credentials for this account slot.",
"Usage fetch failed.",
"Polling deferred until a limit resets.",
"Unrecognized claude-swap status: future_status",
"No usage windows reported.",
])
@@ -277,7 +277,7 @@ struct CLICardsClaudeSwapTests {
@Test
func `active sentinel account remains active and metrics less in full and brief cards`() async {
let problem = "Usage fetch failed."
let problem = "Polling deferred until a limit resets."
let output = await CLIClaudeSwapCards.fetch(
eligible: true,
executablePath: "/fake/cswap",
@@ -313,6 +313,42 @@ struct CLICardsClaudeSwapTests {
#expect(rows.first?.usedPercent == nil)
}
@Test
func `unavailable at limit windows keep metrics and name the exhausted window`() async {
let reset = Date(timeIntervalSince1970: 1_700_003_600)
let output = await CLIClaudeSwapCards.fetch(
eligible: true,
executablePath: "/fake/cswap",
renderOptions: self.renderOptions(),
ambientFetch: { self.ambientOutput(failed: true) },
accountListReader: { _ in
ClaudeSwapAccountList(activeAccountNumber: 1, accounts: [
ClaudeSwapAccountRow(
number: 1,
email: "limited@example.com",
isActive: true,
usageStatus: .unavailable,
fiveHour: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset),
sevenDay: ClaudeSwapUsageWindow(usedPercent: 100, resetsAt: reset)),
self.row(number: 2),
])
})
#expect(output.exitCode == .success)
let activeCard = output.cards.first
#expect(activeCard?.accountLine == "limited@example.com")
#expect(activeCard?.isActive == true)
#expect(activeCard?.accountProblem ==
"Session limit reached. Resets in 1h. Weekly limit reached. Resets in 1h.")
#expect(activeCard?.metrics.isEmpty == false)
#expect(activeCard?.metrics.contains { $0.remainingPercent == 0 } == true)
#expect(activeCard?.accountProblem?.contains("Usage fetch failed") != true)
let rows = CLICardsBriefRenderer.makeRows(cards: activeCard.map { [$0] } ?? [])
#expect(rows.first?.accountProblem?.contains("Session limit reached") == true)
#expect(rows.first?.usedPercent == 100)
}
@Test
func `blank executable path preserves ambient output and fails distinctly`() async {
let ambient = self.ambientOutput()
+6 -2
View File
@@ -168,8 +168,12 @@ The accepted multi-account design in
cards, a list failure retains the current ambient output, adds a distinct `Claude (claude-swap)` footer entry, and
exits non-zero.
- Sentinel statuses (`token_expired`, `api_key`, `keychain_unavailable`, `no_credentials`,
`unavailable`, and unknown future values) render as per-account notes instead of usage bars in both full and brief
cards. Active rows are marked `[active]`; no claude-swap row infers a plan badge.
and unknown future values) render as per-account notes instead of usage bars in both full and brief cards. When
`unavailable` means claude-swap deferred polling because a window is at 100%, CodexBar keeps that slot's last
projected usage bars and names the exhausted window (5-hour session, 7-day weekly, and/or a scoped model such as
Fable) plus its reset time — not "Usage fetch failed." A first refresh that is already `unavailable` with no
retained windows still notes that polling is deferred. Active rows are marked `[active]`; no claude-swap row infers
a plan badge.
- Switching: an inactive account with usable source credentials shows “Switch Account…”. Clicking it runs exactly
`cswap --switch-to <slot> --json`, validates the versioned result and requested slot, then refreshes both ambient
Claude usage and every claude-swap account card. Switches are serialized; no automatic switching occurs. While