codeburn/mac/Tests/CodeBurnMenubarTests/KimiUsageParsingTests.swift
reviewer 576f47d854 menubar: add Kimi Code subscription quota tracking
Read ~/.kimi-code credentials directly and poll api.kimi.com
/coding/v1/usages for the weekly quota and rate-limit windows,
mirroring the existing Codex/Claude quota services.

- New KimiSubscriptionService with lenient decoding (string/number
  values, enum-style time units, derived used from remaining) and
  429 backoff
- Quota chip on the Kimi Code tab, Plan insight, and a Kimi tab in
  Settings with connect/disconnect
- Independent refresh cadence anchor so Kimi-only setups don't poll
  on every payload tick
- Expired tokens surface as terminal with automatic recovery once
  the CLI refreshes the credential file
- Keep insights visible for quota-capable providers on empty days
- Widen Settings so six tabs don't collapse into the overflow menu
2026-07-23 21:31:55 +02:00

114 lines
4.7 KiB
Swift

import XCTest
@testable import CodeBurnMenubar
/// Fixture-driven decode tests for the Kimi Code /coding/v1/usages response.
/// The API has shipped numbers as both JSON numbers and strings, and the
/// reset timestamp under several key spellings (resetTime / reset_at / ...),
/// so the parser must tolerate all of them.
final class KimiUsageParsingTests: XCTestCase {
func testParsesNumericShapeWithResetTime() throws {
let json = """
{
"usage": {"limit": 100, "used": 40, "remaining": 60, "resetTime": "2026-07-30T12:00:00Z"},
"limits": [
{"window": {"duration": 5, "timeUnit": "hour"},
"detail": {"limit": 20, "used": 10, "remaining": 10, "resetTime": "2026-07-23T21:00:00Z"}}
]
}
""".data(using: .utf8)!
let usage = try KimiSubscriptionService.parseUsage(data: json)
XCTAssertEqual(usage.primary?.limit, 100)
XCTAssertEqual(usage.primary?.used, 40)
XCTAssertEqual(usage.primary?.usedPercent ?? -1, 40, accuracy: 0.001)
XCTAssertEqual(usage.primary?.remaining, 60)
XCTAssertNotNil(usage.primary?.resetsAt)
XCTAssertEqual(usage.details.count, 1)
XCTAssertEqual(usage.details.first?.label, "5-hour")
XCTAssertEqual(usage.details.first?.usedPercent ?? -1, 50, accuracy: 0.001)
}
func testParsesStringNumbersAndSnakeCaseReset() throws {
let json = """
{
"usage": {"limit": "500", "used": "123", "remaining": "377", "reset_at": "2026-07-30T12:00:00.000Z"},
"limits": []
}
""".data(using: .utf8)!
let usage = try KimiSubscriptionService.parseUsage(data: json)
XCTAssertEqual(usage.primary?.limit, 500)
XCTAssertEqual(usage.primary?.used, 123)
XCTAssertNotNil(usage.primary?.resetsAt)
}
func testWeeklyWindowLabel() throws {
let json = """
{
"limits": [
{"window": {"duration": 7, "timeUnit": "day"},
"detail": {"limit": 1000, "used": 250}}
]
}
""".data(using: .utf8)!
let usage = try KimiSubscriptionService.parseUsage(data: json)
XCTAssertNil(usage.primary)
XCTAssertEqual(usage.details.first?.label, "Weekly")
}
func testEpochResetTime() throws {
let json = """
{"usage": {"limit": 10, "used": 5, "resetTime": "1784900000"}}
""".data(using: .utf8)!
let usage = try KimiSubscriptionService.parseUsage(data: json)
XCTAssertEqual(usage.primary?.resetsAt, Date(timeIntervalSince1970: 1_784_900_000))
}
func testNumericEpochResetTime() throws {
// A JSON number (not string) must not fail the whole decode.
let json = """
{"usage": {"limit": 10, "used": 5, "resetTime": 1784900000}}
""".data(using: .utf8)!
let usage = try KimiSubscriptionService.parseUsage(data: json)
XCTAssertEqual(usage.primary?.resetsAt, Date(timeIntervalSince1970: 1_784_900_000))
}
func testLiveResponseShape() {
// Captured from GET https://api.kimi.com/coding/v1/usages (2026-07-23).
let json = """
{
"user": {"userId": "x", "region": "REGION_OVERSEA",
"membership": {"level": "LEVEL_INTERMEDIATE"}},
"usage": {"limit": "100", "used": "5", "remaining": "95",
"resetTime": "2026-07-30T13:27:17.211180Z"},
"limits": [
{"window": {"duration": 300, "timeUnit": "TIME_UNIT_MINUTE"},
"detail": {"limit": "100", "remaining": "100",
"resetTime": "2026-07-23T23:27:17.211180Z"}}
],
"parallel": {"limit": "20"}
}
""".data(using: .utf8)!
let usage = try! KimiSubscriptionService.parseUsage(data: json)
XCTAssertEqual(usage.plan, "Intermediate")
XCTAssertEqual(usage.parallelLimit, 20)
XCTAssertEqual(usage.primary?.label, "Weekly")
XCTAssertEqual(usage.primary?.usedPercent ?? -1, 5, accuracy: 0.001)
// 300 minutes rolls up to a 5-hour label; used derives from remaining.
XCTAssertEqual(usage.details.count, 1)
XCTAssertEqual(usage.details.first?.label, "5-hour")
XCTAssertEqual(usage.details.first?.usedPercent ?? -1, 0, accuracy: 0.001)
XCTAssertNotNil(usage.details.first?.resetsAt)
}
func testEmptyEnvelopeThrows() {
let json = "{}".data(using: .utf8)!
XCTAssertThrowsError(try KimiSubscriptionService.parseUsage(data: json))
}
func testZeroLimitWindowDropped() throws {
let json = """
{"usage": {"limit": 0, "used": 0}, "limits": []}
""".data(using: .utf8)!
XCTAssertThrowsError(try KimiSubscriptionService.parseUsage(data: json))
}
}