Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions Tests/StackNudgePanelCoreTests/AntigravityUsageTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import XCTest

@testable import StackNudgePanelCore

// classify is the not-in-use / broken / ok split the Usage tab keys its error
// state on: agy not running stays silent, but agy answering with a body we can't
// read surfaces an error, the same way Claude tells a missing CLI apart from a
// failed one.
final class AntigravityUsageTests: XCTestCase {

private func data(_ json: String) -> Data { Data(json.utf8) }

// A minimal GetUserStatus body with one model quota — enough for parse to
// build a snapshot.
private let validBody = """
{"userStatus":{"cascadeModelConfigData":{"clientModelConfigs":[
{"label":"Claude Opus 4.6","quotaInfo":{"remainingFraction":0.6}}
]}}}
"""

func test_nilData_isUnreachable() {
XCTAssertEqual(AntigravityUsageProbe.classify(nil), .unreachable)
}

// agy answered, but the shape isn't what we expect — a real break, not
// "not in use".
func test_respondedButUnparseable_isUnparseable() {
XCTAssertEqual(AntigravityUsageProbe.classify(data("{\"unexpected\":true}")), .unparseable)
XCTAssertEqual(AntigravityUsageProbe.classify(data("not json")), .unparseable)
}

// A body with the envelope but no models parses to nothing, which is still
// "responded but nothing usable" — unparseable, not unreachable.
func test_respondedWithNoModels_isUnparseable() {
let empty = "{\"userStatus\":{\"cascadeModelConfigData\":{\"clientModelConfigs\":[]}}}"
XCTAssertEqual(AntigravityUsageProbe.classify(data(empty)), .unparseable)
}

func test_validBody_isOk() {
guard case .ok(let snapshot) = AntigravityUsageProbe.classify(data(validBody)) else {
return XCTFail("expected .ok")
}
XCTAssertEqual(snapshot.models.count, 1)
XCTAssertEqual(snapshot.models.first?.label, "Claude Opus 4.6")
XCTAssertEqual(snapshot.models.first?.tier.utilization, 40) // (1 - 0.6) * 100
}
}
67 changes: 67 additions & 0 deletions Tests/StackNudgePanelCoreTests/QuotaResetTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,73 @@ final class QuotaResetTests: XCTestCase {
XCTAssertNil(QuotaReset.relativeLabel(until: ahead(-2 * 3600), now: now))
}

// MARK: - absoluteLabel

// Pinned to a fixed instant and an explicit timezone: the whole point of
// this label is that a Codex unix timestamp and an Antigravity ISO 8601
// string come out in the same shape Claude's CLI prints, so the exact
// characters are the contract.
private let london = TimeZone(identifier: "Europe/London")!
private let noon = Date(timeIntervalSince1970: 1_719_748_800) // 30 Jun 2024, 13:00 BST

func testAbsoluteLabelWithMinutes() {
let resets = Date(timeIntervalSince1970: 1_719_769_800)
XCTAssertEqual(QuotaReset.absoluteLabel(until: resets, now: noon, timeZone: london),
"Jun 30 at 6:50pm")
}

// Claude drops ":00" on the hour; so do we.
func testAbsoluteLabelOnTheHour() {
let resets = Date(timeIntervalSince1970: 1_720_058_400)
XCTAssertEqual(QuotaReset.absoluteLabel(until: resets, now: noon, timeZone: london),
"Jul 4 at 3am")
}

func testAbsoluteLabelAtMidnight() {
let resets = Date(timeIntervalSince1970: 1_722_466_800)
XCTAssertEqual(QuotaReset.absoluteLabel(until: resets, now: noon, timeZone: london),
"Aug 1 at 12am")
}

// Same instant, two timezones. Claude's CLI reports the reset in the
// timezone on the account; we render every client's in the machine's.
func testAbsoluteLabelRendersInTheGivenTimezone() {
let resets = Date(timeIntervalSince1970: 1_719_769_800)
XCTAssertEqual(QuotaReset.absoluteLabel(until: resets, now: noon,
timeZone: TimeZone(identifier: "UTC")!),
"Jun 30 at 5:50pm")
}

func testAbsoluteLabelIsNilOnceElapsed() {
XCTAssertNil(QuotaReset.absoluteLabel(until: now, now: now, timeZone: london))
XCTAssertNil(QuotaReset.absoluteLabel(until: ahead(-60), now: now, timeZone: london))
}

// What we render, ClaudeCliQuotaProbe reads back. Rendered in the host's
// timezone because that's what parseResetsAt assumes when the line carries
// no "(Europe/London)" suffix of its own.
func testAbsoluteLabelRoundTripsThroughTheClaudeParser() {
let resets = Date(timeIntervalSince1970: 1_719_769_800)
let label = QuotaReset.absoluteLabel(until: resets, now: noon)
XCTAssertEqual(ClaudeCliQuotaProbe.parseResetsAt(label ?? "", now: noon), resets)
}

// MARK: - fullLabel

func testFullLabelPairsCountdownWithClockTime() {
let resets = Date(timeIntervalSince1970: 1_719_769_800)
let label = QuotaReset.fullLabel(until: resets, now: noon, timeZone: london)
// The countdown half is RelativeDateTimeFormatter's, so its wording is
// the host's locale; only the pairing and the clock half are ours.
XCTAssertEqual(label?.hasSuffix(" \u{00B7} Jun 30 at 6:50pm"), true)
let countdown = QuotaReset.relativeLabel(until: resets, now: noon)
XCTAssertEqual(label?.hasPrefix(countdown ?? "#"), true)
}

func testFullLabelIsNilOnceElapsed() {
XCTAssertNil(QuotaReset.fullLabel(until: ahead(-60), now: now, timeZone: london))
}

// MARK: - remaining

func testRemainingIsNilAtAndAfterTheDeadline() {
Expand Down
61 changes: 61 additions & 0 deletions Tests/StackNudgePanelCoreTests/QuotaSnapshotTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import XCTest

@testable import StackNudgePanelCore

// hasTier is the one question two callers have to answer identically: which
// clients get a row in the Usage tab, and whether an error replaces a client's
// bars or sits above them. They used to disagree — the view asked "is the
// snapshot non-nil", which is a weaker question than "did anything parse into a
// tier" — and a snapshot that landed with no recognised tier drew a stale
// warning above an empty pane, swallowing the message saying what had failed.
final class QuotaSnapshotTests: XCTestCase {

private func tier(_ used: Double) -> QuotaTier {
QuotaTier(utilization: used, resetsAt: nil)
}

// MARK: - Claude

private func claude(five: QuotaTier? = nil, week: QuotaTier? = nil,
opus: QuotaTier? = nil, sonnet: QuotaTier? = nil) -> QuotaSnapshot {
QuotaSnapshot(fiveHour: five, sevenDay: week,
sevenDayOpus: opus, sevenDaySonnet: sonnet, planType: "max")
}

func test_claude_anySingleTierCounts() {
XCTAssertTrue(claude(five: tier(2)).hasTier)
XCTAssertTrue(claude(week: tier(23)).hasTier)
XCTAssertTrue(claude(opus: tier(12)).hasTier)
XCTAssertTrue(claude(sonnet: tier(0)).hasTier)
}

// The shape parseResultText produces from a plan whose only bucket line is
// one we don't map yet ("Current month (experimental)"): .ok, a plan type,
// and not a single tier behind it.
func test_claude_snapshotWithNoRecognisedTier() {
XCTAssertFalse(claude().hasTier)
}

// MARK: - Codex

func test_codex_eitherWindowCounts() {
XCTAssertTrue(CodexQuotaSnapshot(primary: tier(20), secondary: nil, planType: "plus").hasTier)
XCTAssertTrue(CodexQuotaSnapshot(primary: nil, secondary: tier(4), planType: "plus").hasTier)
}

// A rollout older than its own rate-limit window: both windows are dropped
// as rolled-over, and what's left parses to a snapshot with nothing in it.
func test_codex_expiredWindowsLeaveNothing() {
XCTAssertFalse(CodexQuotaSnapshot(primary: nil, secondary: nil, planType: "plus").hasTier)
}

// MARK: - Antigravity

func test_antigravity_countsItsModels() {
let model = AntigravityQuotaSnapshot.ModelQuota(label: "Claude Opus 4.6", tier: tier(40))
XCTAssertTrue(AntigravityQuotaSnapshot(planType: "pro", models: [model],
promptCredits: nil, flowCredits: nil).hasTier)
XCTAssertFalse(AntigravityQuotaSnapshot(planType: "pro", models: [],
promptCredits: nil, flowCredits: nil).hasTier)
}
}
97 changes: 97 additions & 0 deletions Tests/StackNudgePanelCoreTests/UsageAvailabilityTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import XCTest

@testable import StackNudgePanelCore

// availableUsageClients decides which clients get a row in the Usage tab. The
// rule used to be "has a non-empty snapshot"; a client that failed simply
// vanished, and the only error the tab could show was Claude's global one. Now a
// client with an error but no snapshot lists itself so the error renders on its
// own detail pane — these pin that, and pin that a Claude error never drops
// Claude just because another client happens to have data.
@MainActor
final class UsageAvailabilityTests: XCTestCase {

private func claudeSnapshot(five: Double? = 10) -> QuotaSnapshot {
QuotaSnapshot(fiveHour: five.map { QuotaTier(utilization: $0, resetsAt: nil) },
sevenDay: nil, sevenDayOpus: nil, sevenDaySonnet: nil, planType: nil)
}

private func codexSnapshot() -> CodexQuotaSnapshot {
CodexQuotaSnapshot(primary: QuotaTier(utilization: 20, resetsAt: nil),
secondary: nil, planType: nil)
}

func test_claudeWithTiers_isListed() {
let nav = PanelNav()
nav.quota = claudeSnapshot()
XCTAssertEqual(nav.availableUsageClients, [.claude])
}

// The new behaviour: an error with no snapshot to fall back on still lists
// the client, so the note has a pane to render on instead of falling through
// to the tab's cold-load empty state.
func test_claudeWithErrorButNoSnapshot_isListed() {
let nav = PanelNav()
nav.quotaErrors[.claude] = "Rate-limited — retrying shortly."
XCTAssertEqual(nav.availableUsageClients, [.claude])
}

func test_claudeWithNeitherSnapshotNorError_isNotListed() {
let nav = PanelNav()
XCTAssertTrue(nav.availableUsageClients.isEmpty)
}

// An all-nil snapshot isn't a usable client, and without an error it stays
// out of the list — matching the pre-existing "empty snapshot isn't a
// client" rule.
func test_emptyClaudeSnapshotWithoutError_isNotListed() {
let nav = PanelNav()
nav.quota = claudeSnapshot(five: nil)
XCTAssertTrue(nav.availableUsageClients.isEmpty)
}

// Regression guard: a Claude error must not drop Claude out of the list just
// because Codex has data. Both are listed; the failing client keeps its row.
func test_claudeErrorWithCodexPresent_listsBoth() {
let nav = PanelNav()
nav.quotaErrors[.claude] = "Couldn't refresh — run `claude /usage` to check your session."
nav.codexQuota = codexSnapshot()
XCTAssertEqual(nav.availableUsageClients, [.claude, .codex])
}

// A held-stale Claude snapshot (error present, but the last-good bars are
// still there) keeps its row too — the error marks it stale rather than
// removing it.
func test_claudeHeldStaleSnapshot_staysListed() {
let nav = PanelNav()
nav.quota = claudeSnapshot()
nav.quotaErrors[.claude] = "Couldn't refresh — run `claude /usage` to check your session."
XCTAssertEqual(nav.availableUsageClients, [.claude])
}

// The predicate is uniform: an error lists its client whichever client it
// is, not only Claude. Antigravity is the one that actually populates an
// error today (agy running but unparseable); Codex is checked the same way
// so a future Codex error could display without another wiring change.
func test_antigravityErrorButNoSnapshot_isListed() {
let nav = PanelNav()
nav.quotaErrors[.antigravity] = "Couldn't read Antigravity usage."
XCTAssertEqual(nav.availableUsageClients, [.antigravity])
}

func test_codexErrorButNoSnapshot_isListed() {
let nav = PanelNav()
nav.quotaErrors[.codex] = "something failed"
XCTAssertEqual(nav.availableUsageClients, [.codex])
}

// Listing order follows UsageClient.allCases (claude, codex, antigravity),
// so a mix of data and errors stays in a stable order.
func test_ordering_followsDeclarationOrder() {
let nav = PanelNav()
nav.quotaErrors[.antigravity] = "err"
nav.codexQuota = codexSnapshot()
nav.quota = claudeSnapshot()
XCTAssertEqual(nav.availableUsageClients, [.claude, .codex, .antigravity])
}
}
28 changes: 26 additions & 2 deletions panel/AntigravityUsage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,42 @@ struct AntigravityQuotaSnapshot: Equatable {
let available: Int
let monthly: Int
}

// See QuotaSnapshot.hasTier. `parse` already refuses to build a snapshot with
// no models, so this holds by construction; stated anyway so all three
// clients answer the question the same way.
var hasTier: Bool { !models.isEmpty }
}

final class AntigravityUsageProbe {

// Outcome of one probe. `unreachable` and `unparseable` are deliberately
// distinct: agy not running (loopback refused) is "not in use" and stays
// silent, whereas agy answering with a body we can't read is a genuine break
// worth surfacing in the Usage tab. Mirrors ClaudeCliQuotaProbe's
// cliMissing-vs-hardFail split.
enum FetchResult: Equatable {
case ok(AntigravityQuotaSnapshot)
case unreachable
case unparseable
}

// Calls completion on the main queue. The loopback request runs off-main.
func fetch(completion: @escaping (AntigravityQuotaSnapshot?) -> Void) {
func fetch(completion: @escaping (FetchResult) -> Void) {
DispatchQueue.global(qos: .utility).async {
let result = AntigravityLocalServer.call("GetUserStatus").flatMap(Self.parse)
let result = Self.classify(AntigravityLocalServer.call("GetUserStatus"))
DispatchQueue.main.async { completion(result) }
}
}

// Split out so the not-in-use / broken / ok mapping is testable without a
// live agy on the loopback port. nil data = the call never connected.
static func classify(_ data: Data?) -> FetchResult {
guard let data else { return .unreachable }
guard let snapshot = parse(data) else { return .unparseable }
return .ok(snapshot)
}

private static let iso: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
Expand Down
9 changes: 9 additions & 0 deletions panel/ClaudeCliQuotaProbe.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ final class ClaudeCliQuotaProbe {

// Main-queue only (mirrors QuotaProbe's threading model).
private(set) var lastProbeFailed = false
// True when the last fetch found no `claude` on PATH, as distinct from the
// CLI running but failing to parse. Lets the caller stay silent for a
// non-Claude user instead of surfacing a "usage unavailable" error, and hold
// a prior snapshot on a genuine hard-fail rather than dropping it.
private(set) var cliMissing = false
private var retryAfterUntil: Date?
private var lastSubscriptionType: String?
private var subscriptionFetched = false
Expand All @@ -32,12 +37,16 @@ final class ClaudeCliQuotaProbe {
private let probeQueue = DispatchQueue(label: "stack-nudge.claude-cli-quota")

func fetch(completion: @escaping (QuotaSnapshot?) -> Void) {
// Reset before the early-return gates so a rate-limited tick reports the
// flag from its own run, not a stale value from an earlier missing-CLI one.
cliMissing = false
if isRateLimited {
completion(nil)
return
}
guard let path = ProcessOutput.claude() else {
lastProbeFailed = true
cliMissing = true
completion(nil)
return
}
Expand Down
5 changes: 5 additions & 0 deletions panel/CodexUsage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ struct CodexQuotaSnapshot: Equatable {
let primary: QuotaTier?
let secondary: QuotaTier?
let planType: String?

// See QuotaSnapshot.hasTier. Reachable here too: `tier` drops a window whose
// reset has already passed, so a rollout left over from last week parses into
// a snapshot with both windows nil.
var hasTier: Bool { primary != nil || secondary != nil }
}

// Reads Codex's account-level rate limits from the newest rollout JSONL under
Expand Down
Loading