diff --git a/Tests/OpenUsageTests/ClaudeLogUsageScannerTests.swift b/Tests/OpenUsageTests/ClaudeLogUsageScannerTests.swift index f0d6e27ae..c8a204ce4 100644 --- a/Tests/OpenUsageTests/ClaudeLogUsageScannerTests.swift +++ b/Tests/OpenUsageTests/ClaudeLogUsageScannerTests.swift @@ -69,26 +69,16 @@ final class ClaudeLogUsageScannerTests: XCTestCase { } func testRejectsLinesTheCcusageSchemaRejects() { - // Missing usage.input_tokens / output_tokens. - XCTAssertNil(ClaudeLogUsageScanner.parseLine(Data( - #"{"timestamp":"2026-02-20T12:00:00Z","message":{"usage":{"output_tokens":5}}}"#.utf8 - ))) - // Unparseable timestamp. - XCTAssertNil(ClaudeLogUsageScanner.parseLine(Data( - #"{"timestamp":"not-a-date","message":{"usage":{"input_tokens":1,"output_tokens":2}}}"#.utf8 - ))) - // Unknown speed value (ccusage's lowercase enum parse fails the line). - XCTAssertNil(ClaudeLogUsageScanner.parseLine(Data( - #"{"timestamp":"2026-02-20T12:00:00Z","message":{"usage":{"input_tokens":1,"output_tokens":2,"speed":"turbo"}}}"#.utf8 - ))) - // Non-semver version marks a foreign log shape. - XCTAssertNil(ClaudeLogUsageScanner.parseLine(Data( - ClaudeLogFixture.usageLine(timestamp: "2026-02-20T12:00:00Z", version: "unknown").utf8 - ))) - // Present-but-empty model. - XCTAssertNil(ClaudeLogUsageScanner.parseLine(Data( - ClaudeLogFixture.usageLine(timestamp: "2026-02-20T12:00:00Z", model: "").utf8 - ))) + let invalidLines = [ + #"{"timestamp":"2026-02-20T12:00:00Z","message":{"usage":{"output_tokens":5}}}"#, + #"{"timestamp":"not-a-date","message":{"usage":{"input_tokens":1,"output_tokens":2}}}"#, + #"{"timestamp":"2026-02-20T12:00:00Z","message":{"usage":{"input_tokens":1,"output_tokens":2,"speed":"turbo"}}}"#, + ClaudeLogFixture.usageLine(timestamp: "2026-02-20T12:00:00Z", model: "") + ] + + for line in invalidLines { + XCTAssertNil(ClaudeLogUsageScanner.parseLine(Data(line.utf8)), line) + } } func testSyntheticModelKeepsEntryWithoutModel() throws { @@ -100,29 +90,25 @@ final class ClaudeLogUsageScannerTests: XCTestCase { XCTAssertEqual(entry.tokens.totalTokens, 10) } - func testSemverPrefix() { - XCTAssertTrue(ClaudeLogUsageScanner.isSemverPrefix("1.0.24")) - XCTAssertTrue(ClaudeLogUsageScanner.isSemverPrefix("1.0.24-beta.1")) - XCTAssertFalse(ClaudeLogUsageScanner.isSemverPrefix("unknown")) - XCTAssertFalse(ClaudeLogUsageScanner.isSemverPrefix("1.0")) - XCTAssertFalse(ClaudeLogUsageScanner.isSemverPrefix("1.0.")) + func testLogParsingAcceptsSemverPrefixesAndRejectsIncompleteVersions() { + for (version, accepted) in [("1.0.24", true), ("1.0.24-beta.1", true), + ("unknown", false), ("1.0", false), ("1.0.", false)] { + let line = ClaudeLogFixture.usageLine(timestamp: "2026-02-20T12:00:00Z", version: version) + XCTAssertEqual(ClaudeLogUsageScanner.parseLine(Data(line.utf8)) != nil, accepted, version) + } } - // Ported from ccusage `rejects_null_schema_fields_like_typescript_loader`. - func testRejectsNullSchemaFields() { - XCTAssertTrue(ClaudeLogUsageScanner.hasUnsupportedNullField(Data( - #"{"message":{"usage":{"speed":null}}}"#.utf8 - ))) - XCTAssertTrue(ClaudeLogUsageScanner.hasUnsupportedNullField(Data( - #"{"message":{"model":null,"usage":{"input_tokens":0}}}"#.utf8 - ))) - XCTAssertTrue(ClaudeLogUsageScanner.hasUnsupportedNullField(Data( - #"{"sessionId":null,"message":{"usage":{"input_tokens":0}}}"#.utf8 - ))) - // `content: null` is fine — only the known schema fields reject nulls. - XCTAssertFalse(ClaudeLogUsageScanner.hasUnsupportedNullField(Data( - #"{"message":{"content":null,"usage":{"input_tokens":0}}}"#.utf8 - ))) + func testLogParsingRejectsNullSchemaFieldsButAllowsNullContent() { + let cases: [(line: String, accepted: Bool)] = [ + (#"{"timestamp":"2026-02-20T12:00:00Z","message":{"usage":{"input_tokens":1,"output_tokens":2,"speed":null}}}"#, false), + (#"{"timestamp":"2026-02-20T12:00:00Z","message":{"model":null,"usage":{"input_tokens":1,"output_tokens":2}}}"#, false), + (#"{"timestamp":"2026-02-20T12:00:00Z","sessionId":null,"message":{"usage":{"input_tokens":1,"output_tokens":2}}}"#, false), + (#"{"timestamp":"2026-02-20T12:00:00Z","message":{"content":null,"usage":{"input_tokens":1,"output_tokens":2}}}"#, true) + ] + + for entry in cases { + XCTAssertEqual(!ClaudeLogUsageScanner.parseFile(Data(entry.line.utf8)).isEmpty, entry.accepted, entry.line) + } } func testParseFileSkipsNonUsageAndMalformedLines() { @@ -156,14 +142,14 @@ final class ClaudeLogUsageScannerTests: XCTestCase { } func testParseFileExpandsOnlyAdvisorIterationsWithoutRecountingMainUsage() { - let line = #"{"timestamp":"2026-02-20T12:00:00.000Z","requestId":"req_1","costUSD":1.23,"message":{"id":"msg_1","model":"main-model","usage":{"input_tokens":2,"output_tokens":491,"cache_creation_input_tokens":7853,"cache_read_input_tokens":226584,"iterations":[{"type":"message","input_tokens":1,"output_tokens":200},{"type":"advisor_message","model":"claude-test-model","input_tokens":10,"output_tokens":2,"cache_creation_input_tokens":3,"cache_read_input_tokens":4},{"type":"message","input_tokens":1,"output_tokens":291}]}}}"# + let line = #"{"timestamp":"2026-02-20T12:00:00.000Z","requestId":"req_1","costUSD":1.23,"message":{"id":"msg_1","model":"main-model","usage":{"input_tokens":2,"output_tokens":5,"cache_creation_input_tokens":8,"cache_read_input_tokens":20,"iterations":[{"type":"message","input_tokens":1,"output_tokens":3},{"type":"advisor_message","model":"claude-test-model","input_tokens":10,"output_tokens":2,"cache_creation_input_tokens":3,"cache_read_input_tokens":4}]}}}"# let entries = ClaudeLogUsageScanner.parseFile(Data(line.utf8)) XCTAssertEqual(entries.count, 2) XCTAssertEqual(entries[0].model, "main-model") XCTAssertEqual(entries[0].tokens, TokenBreakdown( - input: 2, cacheWrite5m: 7853, cacheRead: 226584, output: 491 + input: 2, cacheWrite5m: 8, cacheRead: 20, output: 5 )) XCTAssertEqual(entries[0].costUSD, 1.23) XCTAssertEqual(entries[1].model, "claude-test-model") @@ -218,11 +204,8 @@ final class ClaudeLogUsageScannerTests: XCTestCase { entry(messageID: "msg-parent", requestID: "req-sidechain-replay", isSidechain: true, cacheRead: 50_000, output: 10), entry(messageID: "msg-sidechain-answer", requestID: "req-sidechain-answer", isSidechain: true, cacheRead: 700, output: 30) ]) - XCTAssertEqual(deduped.count, 2) - XCTAssertEqual(deduped[0].requestID, "req-parent") - XCTAssertEqual(deduped[0].tokens.cacheRead, 20) - XCTAssertEqual(deduped[1].messageID, "msg-sidechain-answer") - XCTAssertEqual(deduped[1].tokens.cacheRead, 700) + XCTAssertEqual(deduped.map(\.requestID), ["req-parent", "req-sidechain-answer"]) + XCTAssertEqual(deduped.map(\.tokens.cacheRead), [20, 700]) } // Ported from `refreshes_dedupe_indexes_when_parent_replaces_sidechain_replay`. @@ -232,9 +215,8 @@ final class ClaudeLogUsageScannerTests: XCTestCase { entry(messageID: "msg-parent", requestID: "req-parent", isSidechain: false, cacheRead: 20, output: 10), entry(messageID: "msg-parent", requestID: "req-parent", isSidechain: false, cacheRead: 5, output: 5) ]) - XCTAssertEqual(deduped.count, 1) - XCTAssertEqual(deduped[0].requestID, "req-parent") - XCTAssertEqual(deduped[0].tokens.cacheRead, 20) + XCTAssertEqual(deduped.map(\.requestID), ["req-parent"]) + XCTAssertEqual(deduped.first?.tokens.cacheRead, 20) } func testDistinctRequestIDsWithoutSidechainAreBothKept() { @@ -327,33 +309,20 @@ final class ClaudeLogUsageScannerTests: XCTestCase { ]) } - func testAggregateUnknownModelOnlyLeavesDayUnbacked() { + func testUnpriceableModelsStayUnbackedAndOnlyNamedModelsWarn() { let day = localDay("2026-02-20T12:00:00.000Z") - var unknown = entry(messageID: "m1", requestID: "r1", isSidechain: false, cacheRead: 0, output: 0) - unknown.model = "mystery-model" - unknown.tokens = TokenBreakdown(input: 10, output: 5) - let scan = ClaudeLogUsageScanner.aggregate(entries: [unknown], since: .distantPast, pricing: pricing) + for model in ["mystery-model", nil] as [String?] { + var unpriced = entry(messageID: "m1", requestID: "r1", isSidechain: false, cacheRead: 0, output: 0) + unpriced.model = model + unpriced.tokens = TokenBreakdown(input: 10, output: 5) - // A day with nothing priceable produces no series entry at all (→ "No data"), but the - // unknown-model warning still names what was excluded. - XCTAssertTrue(scan.series.daily.isEmpty) - XCTAssertEqual(scan.unknownModelsByDay[day], ["mystery-model"]) - XCTAssertEqual(scan.modelUsage?.daily ?? [], []) - } - - func testAggregateSyntheticModelIsExcludedWithoutWarning() { - var synthetic = entry(messageID: "m1", requestID: "r1", isSidechain: false, cacheRead: 0, output: 0) - synthetic.model = nil - synthetic.tokens = TokenBreakdown(input: 10, output: 5) + let scan = ClaudeLogUsageScanner.aggregate(entries: [unpriced], since: .distantPast, pricing: pricing) - let scan = ClaudeLogUsageScanner.aggregate(entries: [synthetic], since: .distantPast, pricing: pricing) - - // No model and no carried cost: unpriceable, so excluded from totals — and with no name to - // warn about, no unknown-model entry either. - XCTAssertTrue(scan.series.daily.isEmpty) - XCTAssertTrue(scan.unknownModelsByDay.isEmpty) - XCTAssertEqual(scan.modelUsage?.daily ?? [], []) + XCTAssertTrue(scan.series.daily.isEmpty) + XCTAssertEqual(scan.unknownModelsByDay[day], model.map { [$0] }) + XCTAssertEqual(scan.modelUsage?.daily ?? [], []) + } } func testAggregateSyntheticModelWithCarriedCostStillCounts() { diff --git a/Tests/OpenUsageTests/ClaudeProviderTests.swift b/Tests/OpenUsageTests/ClaudeProviderTests.swift index a403e4c52..70f75ab5f 100644 --- a/Tests/OpenUsageTests/ClaudeProviderTests.swift +++ b/Tests/OpenUsageTests/ClaudeProviderTests.swift @@ -13,56 +13,21 @@ final class ClaudeAuthStoreTests: XCTestCase { } func testCredentialDiagnosticsLabelIsTokenFreeWithSourceRefreshAndExpiredFlags() { - // The info-level "refresh start" / fallback diagnostics must name the source kind and whether each - // candidate carries a refresh token + is already expired — never any token value (#738 diagnosis). - let now = Date(timeIntervalSince1970: 1_000_000) // 1_000_000_000 ms - - let fresh = ClaudeCredentialState( - oauth: ClaudeOAuth(accessToken: "ACCESS_SECRET", refreshToken: "REFRESH_SECRET", expiresAt: 2_000_000_000_000), - source: .keychainCurrentUser(service: "Claude Code-credentials"), - fullData: nil, - inferenceOnly: false - ) - XCTAssertEqual(fresh.diagnosticsLabel(now: now), "keychainCurrentUser refresh=yes expired=no") - XCTAssertFalse(fresh.diagnosticsLabel(now: now).contains("SECRET")) // never leaks token values - - // No refresh token + an already-expired access token: the #738 shape that can never self-heal. - let lockedOut = ClaudeCredentialState( - oauth: ClaudeOAuth(accessToken: "a", refreshToken: nil, expiresAt: 1), - source: .file, - fullData: nil, - inferenceOnly: false - ) - XCTAssertEqual(lockedOut.diagnosticsLabel(now: now), "file refresh=no expired=yes") - - // Empty refresh token counts as absent; missing expiry is reported as unknown, not assumed fresh. - let unknownExpiry = ClaudeCredentialState( - oauth: ClaudeOAuth(accessToken: "a", refreshToken: "", expiresAt: nil), - source: .keychainLegacy(service: "svc"), - fullData: nil, - inferenceOnly: false - ) - XCTAssertEqual(unknownExpiry.diagnosticsLabel(now: now), "keychainLegacy refresh=no expired=unknown") - } - - func testPrefersCurrentUserKeychainCredentialsBeforeFile() { - let files = FakeFiles([ - "/tmp/claude/.credentials.json": #"{"claudeAiOauth":{"accessToken":"file-token","subscriptionType":"pro"}}"# - ]) - let keychain = ServiceKeychain() - let store = ClaudeAuthStore( - environment: FakeEnvironment(["CLAUDE_CONFIG_DIR": "/tmp/claude"]), - files: files, - keychain: keychain - ) - let hashedService = store.keychainServiceCandidates().first! - keychain.currentUserValues[hashedService] = #"{"claudeAiOauth":{"accessToken":"keychain-token","subscriptionType":"max"}}"# - - let credentials = store.loadCredentialCandidates().first + let now = Date(timeIntervalSince1970: 1_000_000) + let cases: [(oauth: ClaudeOAuth, source: ClaudeCredentialState.Source, expected: String)] = [ + (ClaudeOAuth(accessToken: "ACCESS_SECRET", refreshToken: "REFRESH_SECRET", expiresAt: 2_000_000_000_000), + .keychainCurrentUser(service: "Claude Code-credentials"), "keychainCurrentUser refresh=yes expired=no"), + (ClaudeOAuth(accessToken: "a", expiresAt: 1), .file, "file refresh=no expired=yes"), + (ClaudeOAuth(accessToken: "a", refreshToken: ""), .keychainLegacy(service: "svc"), + "keychainLegacy refresh=no expired=unknown") + ] - XCTAssertTrue(hashedService.hasPrefix("Claude Code-credentials-")) - XCTAssertEqual(credentials?.oauth.accessToken, "keychain-token") - XCTAssertEqual(credentials?.oauth.subscriptionType, "max") + for entry in cases { + let state = ClaudeCredentialState(oauth: entry.oauth, source: entry.source, fullData: nil, inferenceOnly: false) + let label = state.diagnosticsLabel(now: now) + XCTAssertEqual(label, entry.expected) + XCTAssertFalse(label.contains("SECRET")) + } } func testPrefersKeychainOverFileEvenWhenFileTokenExpiresLater() { @@ -85,7 +50,7 @@ final class ClaudeAuthStoreTests: XCTestCase { let candidates = store.loadCredentialCandidates() XCTAssertEqual(candidates.map(\.oauth.accessToken), ["keychain-token", "file-token"]) - XCTAssertEqual(store.loadCredentialCandidates().first?.oauth.accessToken, "keychain-token") + XCTAssertEqual(candidates.first?.oauth.subscriptionType, "max") } func testEnvironmentTokenIsInferenceOnly() { @@ -246,12 +211,8 @@ final class ClaudeUsageMapperTests: XCTestCase { headers: [:], body: Data(""" { - "five_hour": { "utilization": 10, "resets_at": "2099-01-01T00:00:00.000Z" }, - "seven_day": { "utilization": 20, "resets_at": "2099-01-01T00:00:00.000Z" }, "seven_day_sonnet": null, "limits": [ - { "kind": "session", "group": "session", "percent": 10, "resets_at": "2099-01-01T00:00:00.000Z" }, - { "kind": "weekly_all", "group": "weekly", "percent": 20, "resets_at": "2099-01-08T00:00:00.000Z" }, { "kind": "weekly_scoped", "group": "weekly", "percent": 7, "resets_at": "2099-01-08T00:00:00.000Z", "scope": { "model": { "display_name": "Fable", "id": null }, "surface": null } } @@ -293,37 +254,20 @@ final class ClaudeUsageMapperTests: XCTestCase { XCTAssertNil(progress(mapped.lines, "Extra usage spent")) } - func testMapsResetsAtFromMicrosecondTimestampWithoutTimezone() throws { - let response = HTTPResponse( - statusCode: 200, - headers: [:], - body: Data(#"{"five_hour":{"utilization":0,"resets_at":"2099-06-01T12:00:00.123456"}}"#.utf8) - ) - - let mapped = try ClaudeUsageMapper.mapUsageResponse( - response, - credentials: ClaudeOAuth(subscriptionType: "pro") - ) - - let resetsAt = try XCTUnwrap(progress(mapped.lines, "Session")?.resetsAt) - XCTAssertEqual(OpenUsageISO8601.string(from: resetsAt), "2099-06-01T12:00:00.123Z") - } - - func testMapsResetsAtFromUnixEpochNumber() throws { - let epochSeconds = 2_099_010_100.0 - let response = HTTPResponse( - statusCode: 200, - headers: [:], - body: Data(#"{"five_hour":{"utilization":0,"resets_at":2099010100}}"#.utf8) - ) - - let mapped = try ClaudeUsageMapper.mapUsageResponse( - response, - credentials: ClaudeOAuth(subscriptionType: "pro") - ) + func testMapsResetDatesFromMicrosecondTimestampsAndUnixEpochs() throws { + let cases: [(value: String, expected: Date)] = [ + (#""2099-06-01T12:00:00.123456""#, OpenUsageISO8601.date(from: "2099-06-01T12:00:00.123Z")!), + ("2099010100", Date(timeIntervalSince1970: 2_099_010_100)) + ] - let resetsAt = try XCTUnwrap(progress(mapped.lines, "Session")?.resetsAt) - XCTAssertEqual(resetsAt.timeIntervalSince1970, epochSeconds, accuracy: 1) + for entry in cases { + let response = HTTPResponse(statusCode: 200, headers: [:], body: Data(""" + {"five_hour":{"utilization":0,"resets_at":\(entry.value)}} + """.utf8)) + let mapped = try ClaudeUsageMapper.mapUsageResponse(response, credentials: ClaudeOAuth(subscriptionType: "pro")) + let resetsAt = try XCTUnwrap(progress(mapped.lines, "Session")?.resetsAt) + XCTAssertEqual(resetsAt.timeIntervalSince1970, entry.expected.timeIntervalSince1970, accuracy: 0.001) + } } func testRateLimitRetryAfterBadge() { @@ -376,14 +320,9 @@ final class ClaudeProviderTests: XCTestCase { ) ]) let provider = ClaudeProvider( - authStore: ClaudeAuthStore( - environment: FakeEnvironment(["CLAUDE_CONFIG_DIR": "/tmp/claude"]), - files: FakeFiles([ - "/tmp/claude/.credentials.json": #"{"claudeAiOauth":{"accessToken":"token","subscriptionType":"pro","scopes":["user:profile"]}}"# - ]), - keychain: FakeKeychain(), - now: { now } - ), + authStore: configuredAuthStore(files: FakeFiles([ + "/tmp/claude/.credentials.json": #"{"claudeAiOauth":{"accessToken":"token","subscriptionType":"pro","scopes":["user:profile"]}}"# + ]), now: { now }), usageClient: ClaudeUsageClient(httpClient: httpClient), logUsageScanner: ClaudeLogFixture.scanner(home: home), now: { now }, @@ -405,10 +344,6 @@ final class ClaudeProviderTests: XCTestCase { let home = try ClaudeLogFixture.makeHome(files: [ "project-a/today.jsonl": ClaudeLogFixture.usageLine( timestamp: "2026-02-20T16:00:00.000Z", input: 100, output: 50, costUSD: 0.25 - ), - "project-a/yesterday.jsonl": ClaudeLogFixture.usageLine( - timestamp: "2026-02-19T16:00:00.000Z", input: 40, output: 20, costUSD: 0.40, - messageID: "msg_yesterday", requestID: "req_yesterday" ) ]) let httpClient = FakeHTTPClient(response: HTTPResponse(statusCode: 200, headers: [:], body: Data())) @@ -436,14 +371,6 @@ final class ClaudeProviderTests: XCTestCase { MetricValue(number: 0.25, kind: .dollars, estimated: true), MetricValue(number: 150, kind: .count, label: "tokens") ]) - XCTAssertEqual(values(snapshot.lines, "Yesterday"), [ - MetricValue(number: 0.40, kind: .dollars, estimated: true), - MetricValue(number: 60, kind: .count, label: "tokens") - ]) - XCTAssertEqual(values(snapshot.lines, "Last 30 Days"), [ - MetricValue(number: 0.65, kind: .dollars, estimated: true), - MetricValue(number: 210, kind: .count, label: "tokens") - ]) XCTAssertTrue(httpClient.requests.isEmpty) } @@ -509,14 +436,9 @@ final class ClaudeProviderTests: XCTestCase { ) ]) let provider = ClaudeProvider( - authStore: ClaudeAuthStore( - environment: FakeEnvironment(["CLAUDE_CONFIG_DIR": "/tmp/claude"]), - files: FakeFiles([ - "/tmp/claude/.credentials.json": #"{"claudeAiOauth":{"accessToken":"token","subscriptionType":"max","rateLimitTier":"default_claude_max_5x","scopes":["user:inference"]}}"# - ]), - keychain: FakeKeychain(), - now: { now } - ), + authStore: configuredAuthStore(files: FakeFiles([ + "/tmp/claude/.credentials.json": #"{"claudeAiOauth":{"accessToken":"token","subscriptionType":"max","rateLimitTier":"default_claude_max_5x","scopes":["user:inference"]}}"# + ]), now: { now }), usageClient: ClaudeUsageClient(httpClient: httpClient), logUsageScanner: ClaudeLogFixture.scanner(home: home), now: { now }, @@ -593,12 +515,7 @@ final class ClaudeProviderTests: XCTestCase { ) } let provider = ClaudeProvider( - authStore: ClaudeAuthStore( - environment: FakeEnvironment(["CLAUDE_CONFIG_DIR": "/tmp/claude"]), - files: files, - keychain: FakeKeychain(), - now: { now } - ), + authStore: configuredAuthStore(files: files, now: { now }), usageClient: ClaudeUsageClient(httpClient: httpClient), logUsageScanner: ClaudeLogFixture.scanner(home: nil), now: { now }, @@ -625,12 +542,7 @@ final class ClaudeProviderTests: XCTestCase { "/tmp/claude/.credentials.json": #"{"claudeAiOauth":{"accessToken":"fresh-access","refreshToken":"fresh-refresh","expiresAt":4070908800000,"subscriptionType":"pro","scopes":["user:profile"]}}"# ]) let keychain = ServiceKeychain() - let authStore = ClaudeAuthStore( - environment: FakeEnvironment(["CLAUDE_CONFIG_DIR": "/tmp/claude"]), - files: files, - keychain: keychain, - now: { now } - ) + let authStore = configuredAuthStore(files: files, keychain: keychain, now: { now }) // The keychain is always probed first (it's the source of truth), so this exercises the // auth-failure fallback: the stale keychain token's refresh is revoked, and recovery comes from // falling through to the fresh file token — not from any expiry-based reordering. @@ -677,12 +589,7 @@ final class ClaudeProviderTests: XCTestCase { "/tmp/claude/.credentials.json": #"{"claudeAiOauth":{"accessToken":"file-stale","refreshToken":"file-refresh","expiresAt":4070908800000,"subscriptionType":"pro","scopes":["user:profile"]}}"# ]) let keychain = ServiceKeychain() - let authStore = ClaudeAuthStore( - environment: FakeEnvironment(["CLAUDE_CONFIG_DIR": "/tmp/claude"]), - files: files, - keychain: keychain, - now: { now } - ) + let authStore = configuredAuthStore(files: files, keychain: keychain, now: { now }) let hashedService = authStore.keychainServiceCandidates().first! keychain.currentUserValues[hashedService] = #"{"claudeAiOauth":{"accessToken":"keychain-stale","refreshToken":"keychain-refresh","expiresAt":4102444800000,"subscriptionType":"max","scopes":["user:profile"]}}"# @@ -720,17 +627,14 @@ final class ClaudeProviderTests: XCTestCase { ) } - let noneAtAll = makeProvider(files: FakeFiles()) - let plainSnapshot = await noneAtAll.refresh() - XCTAssertEqual(badge(plainSnapshot.lines, "Error"), ClaudeAuthError.notLoggedIn.localizedDescription) - - // A stored-but-blank CLI token (whitespace accessToken survives the store's isEmpty check but is - // dropped by the provider's trim filter) is still unusable. - let corruptCLI = makeProvider(files: FakeFiles([ - "~/.claude/.credentials.json": #"{"claudeAiOauth":{"accessToken":" "}}"# - ])) - let corruptSnapshot = await corruptCLI.refresh() - XCTAssertEqual(badge(corruptSnapshot.lines, "Error"), ClaudeAuthError.notLoggedIn.localizedDescription) + let cases = [ + FakeFiles(), + FakeFiles(["~/.claude/.credentials.json": #"{"claudeAiOauth":{"accessToken":" "}}"#]) + ] + for files in cases { + let snapshot = await makeProvider(files: files).refresh() + XCTAssertEqual(badge(snapshot.lines, "Error"), ClaudeAuthError.notLoggedIn.localizedDescription) + } } func testRateLimitedResponseMapsToRetryBadgeNotError() async { @@ -741,14 +645,9 @@ final class ClaudeProviderTests: XCTestCase { body: Data() )) let provider = ClaudeProvider( - authStore: ClaudeAuthStore( - environment: FakeEnvironment(["CLAUDE_CONFIG_DIR": "/tmp/claude"]), - files: FakeFiles([ - "/tmp/claude/.credentials.json": #"{"claudeAiOauth":{"accessToken":"token","subscriptionType":"pro","scopes":["user:profile"]}}"# - ]), - keychain: FakeKeychain(), - now: { now } - ), + authStore: configuredAuthStore(files: FakeFiles([ + "/tmp/claude/.credentials.json": #"{"claudeAiOauth":{"accessToken":"token","subscriptionType":"pro","scopes":["user:profile"]}}"# + ]), now: { now }), usageClient: ClaudeUsageClient(httpClient: httpClient), logUsageScanner: ClaudeLogFixture.scanner(home: nil), now: { now }, @@ -788,14 +687,9 @@ final class ClaudeProviderTests: XCTestCase { return HTTPResponse(statusCode: 429, headers: ["retry-after": "600"], body: Data()) } let provider = ClaudeProvider( - authStore: ClaudeAuthStore( - environment: FakeEnvironment(["CLAUDE_CONFIG_DIR": "/tmp/claude"]), - files: FakeFiles([ - "/tmp/claude/.credentials.json": #"{"claudeAiOauth":{"accessToken":"token","subscriptionType":"pro","scopes":["user:profile"]}}"# - ]), - keychain: FakeKeychain(), - now: { clock.now } - ), + authStore: configuredAuthStore(files: FakeFiles([ + "/tmp/claude/.credentials.json": #"{"claudeAiOauth":{"accessToken":"token","subscriptionType":"pro","scopes":["user:profile"]}}"# + ]), now: { clock.now }), usageClient: ClaudeUsageClient(httpClient: httpClient), logUsageScanner: ClaudeLogFixture.scanner(home: nil), now: { clock.now }, @@ -839,12 +733,7 @@ final class ClaudeProviderTests: XCTestCase { return HTTPResponse(statusCode: 400, headers: [:], body: Data("Bad Gateway".utf8)) } let provider = ClaudeProvider( - authStore: ClaudeAuthStore( - environment: FakeEnvironment(["CLAUDE_CONFIG_DIR": "/tmp/claude"]), - files: files, - keychain: FakeKeychain(), - now: { now } - ), + authStore: configuredAuthStore(files: files, now: { now }), usageClient: ClaudeUsageClient(httpClient: httpClient), logUsageScanner: ClaudeLogFixture.scanner(home: nil), now: { now }, @@ -854,7 +743,19 @@ final class ClaudeProviderTests: XCTestCase { let snapshot = await provider.refresh() XCTAssertEqual(badge(snapshot.lines, "Error"), ProviderUsageErrorText.requestFailed(statusCode: 400)) - XCTAssertNotEqual(badge(snapshot.lines, "Error"), ClaudeAuthError.tokenExpired.localizedDescription) + } + + private func configuredAuthStore( + files: FakeFiles, + keychain: KeychainAccessing = FakeKeychain(), + now: @escaping @Sendable () -> Date + ) -> ClaudeAuthStore { + ClaudeAuthStore( + environment: FakeEnvironment(["CLAUDE_CONFIG_DIR": "/tmp/claude"]), + files: files, + keychain: keychain, + now: now + ) } private func badge(_ lines: [MetricLine], _ label: String) -> String? { diff --git a/Tests/OpenUsageTests/CodexLogUsageScannerTests.swift b/Tests/OpenUsageTests/CodexLogUsageScannerTests.swift index 3e911bf29..b9a920e14 100644 --- a/Tests/OpenUsageTests/CodexLogUsageScannerTests.swift +++ b/Tests/OpenUsageTests/CodexLogUsageScannerTests.swift @@ -49,13 +49,9 @@ final class CodexLogUsageScannerTests: XCTestCase { let events = CodexLogUsageScanner.parseFile(Data(lines.utf8)) - XCTAssertEqual(events.count, 2) - XCTAssertEqual(events[0].input, 1000) - XCTAssertEqual(events[0].cached, 100) - XCTAssertEqual(events[0].output, 200) - XCTAssertEqual(events[0].model, "gpt-5.2") - XCTAssertEqual(events[1].input, 500) - XCTAssertEqual(events[1].total, 600) + XCTAssertEqual(events.map { [$0.input, $0.cached, $0.output, $0.total] }, + [[1000, 100, 200, 1200], [500, 50, 100, 600]]) + XCTAssertEqual(events.map(\.model), ["gpt-5.2", "gpt-5.2"]) } func testTotalsOnlyLinesEmitDeltas() { @@ -73,12 +69,8 @@ final class CodexLogUsageScannerTests: XCTestCase { let events = CodexLogUsageScanner.parseFile(Data(lines.utf8)) - XCTAssertEqual(events.count, 2) - XCTAssertEqual(events[0].input, 1000) - XCTAssertEqual(events[1].input, 500) - XCTAssertEqual(events[1].cached, 50) - XCTAssertEqual(events[1].output, 100) - XCTAssertEqual(events[1].total, 600) + XCTAssertEqual(events.map { [$0.input, $0.cached, $0.output, $0.total] }, + [[1000, 100, 200, 1200], [500, 50, 100, 600]]) } func testZeroUsageLinesAreSkipped() { @@ -156,40 +148,17 @@ final class CodexLogUsageScannerTests: XCTestCase { CodexLogFixture.tokenCount( timestamp: "2026-07-12T08:05:00.000Z", last: CodexLogFixture.usage(input: 30, output: 15) - ) - ].joined(separator: "\n") - - let events = CodexLogUsageScanner.parseFile(Data(lines.utf8)) - - XCTAssertEqual(events.map(\.isFast), [false, true, false]) - } - - func testSessionWithoutServiceTierMetadataIsStandard() { - // Rollouts written before Codex recorded the tier (or by older CLIs) carry no - // thread_settings_applied line — they must price at standard rates, never at whatever - // the current config.toml happens to say. - let lines = [ - CodexLogFixture.turnContext(timestamp: "2026-05-12T08:00:00.000Z", model: "gpt-5.2"), + ), + CodexLogFixture.threadSettingsApplied(timestamp: "2026-07-12T08:06:00.000Z", serviceTier: "fast"), CodexLogFixture.tokenCount( - timestamp: "2026-05-12T08:01:00.000Z", - last: CodexLogFixture.usage(input: 10, output: 5) + timestamp: "2026-07-12T08:07:00.000Z", + last: CodexLogFixture.usage(input: 40, output: 20) ) ].joined(separator: "\n") - XCTAssertEqual(CodexLogUsageScanner.parseFile(Data(lines.utf8)).map(\.isFast), [false]) - } - - func testFastServiceTierAlsoMarksEventsFast() { - let lines = [ - CodexLogFixture.threadSettingsApplied(timestamp: "2026-07-12T08:00:00.000Z", serviceTier: "fast"), - CodexLogFixture.tokenCount( - timestamp: "2026-07-12T08:01:00.000Z", - last: CodexLogFixture.usage(input: 10, output: 5), - model: "gpt-5.2" - ) - ].joined(separator: "\n") + let events = CodexLogUsageScanner.parseFile(Data(lines.utf8)) - XCTAssertEqual(CodexLogUsageScanner.parseFile(Data(lines.utf8)).map(\.isFast), [true]) + XCTAssertEqual(events.map(\.isFast), [false, true, false, true]) } func testCachedTokensCapAtInputTokens() { @@ -213,32 +182,20 @@ final class CodexLogUsageScannerTests: XCTestCase { XCTAssertEqual(CodexLogUsageScanner.autoReviewFallback(at: "garbage"), "gpt-5") } - func testAutoReviewLinesKeepSlugAndResolvePricingByLineDate() { - let lines = [ - CodexLogFixture.turnContext(timestamp: "2026-03-10T08:00:00.000Z", model: "codex-auto-review"), - CodexLogFixture.tokenCount( - timestamp: "2026-03-10T08:01:00.000Z", - last: CodexLogFixture.usage(input: 10, output: 5) - ) - ].joined(separator: "\n") - - let event = CodexLogUsageScanner.parseFile(Data(lines.utf8)).first - XCTAssertEqual(event?.model, "codex-auto-review") - XCTAssertEqual(event?.pricingModel, "gpt-5.4") - } - - func testRecentAutoReviewLinesUseLunaPricing() { - let lines = [ - CodexLogFixture.turnContext(timestamp: "2026-08-20T08:00:00.000Z", model: "codex-auto-review"), - CodexLogFixture.tokenCount( - timestamp: "2026-08-20T08:01:00.000Z", - last: CodexLogFixture.usage(input: 10, output: 5) - ) - ].joined(separator: "\n") + func testAutoReviewLinesPreserveSlugAndUseDateSpecificPricing() { + for (date, expectedModel) in [("2026-03-10", "gpt-5.4"), ("2026-08-20", "gpt-5.6-luna")] { + let lines = [ + CodexLogFixture.turnContext(timestamp: "\(date)T08:00:00.000Z", model: "codex-auto-review"), + CodexLogFixture.tokenCount( + timestamp: "\(date)T08:01:00.000Z", + last: CodexLogFixture.usage(input: 10, output: 5) + ) + ].joined(separator: "\n") - let event = CodexLogUsageScanner.parseFile(Data(lines.utf8)).first - XCTAssertEqual(event?.model, "codex-auto-review") - XCTAssertEqual(event?.pricingModel, "gpt-5.6-luna") + let event = CodexLogUsageScanner.parseFile(Data(lines.utf8)).first + XCTAssertEqual(event?.model, "codex-auto-review", date) + XCTAssertEqual(event?.pricingModel, expectedModel, date) + } } // MARK: - Child-session replay (subagents and forks) @@ -259,11 +216,6 @@ final class CodexLogUsageScannerTests: XCTestCase { last: CodexLogFixture.usage(input: 1000, cached: 100, output: 200), totals: CodexLogFixture.usage(input: 1000, cached: 100, output: 200) ), - CodexLogFixture.tokenCount( - timestamp: "2026-05-12T08:03:00.200Z", - last: CodexLogFixture.usage(input: 500, cached: 50, output: 100), - totals: CodexLogFixture.usage(input: 1500, cached: 150, output: 300) - ), CodexLogFixture.taskStarted(timestamp: "2026-05-12T08:03:01.000Z", startedAt: childCreationEpoch + 1), CodexLogFixture.tokenCount( timestamp: "2026-05-12T08:04:00.000Z", @@ -278,11 +230,7 @@ final class CodexLogUsageScannerTests: XCTestCase { let events = CodexLogUsageScanner.parseFile(Data(lines.utf8)) - XCTAssertEqual(events.count, 2) - XCTAssertEqual(events[0].input, 100) - XCTAssertEqual(events[0].output, 20) - XCTAssertEqual(events[1].input, 50) - XCTAssertEqual(events[1].output, 10) + XCTAssertEqual(events.map { [$0.input, $0.output] }, [[100, 20], [50, 10]]) } func testMultiSecondReplayIsFullySkipped() { @@ -297,11 +245,6 @@ final class CodexLogUsageScannerTests: XCTestCase { last: CodexLogFixture.usage(input: 1000, output: 200), totals: CodexLogFixture.usage(input: 1000, output: 200) ), - CodexLogFixture.tokenCount( - timestamp: "2026-05-12T08:03:01.400Z", - last: CodexLogFixture.usage(input: 2000, output: 400), - totals: CodexLogFixture.usage(input: 3000, output: 600) - ), CodexLogFixture.tokenCount( timestamp: "2026-05-12T08:03:02.800Z", last: CodexLogFixture.usage(input: 4000, output: 800), @@ -316,9 +259,7 @@ final class CodexLogUsageScannerTests: XCTestCase { let events = CodexLogUsageScanner.parseFile(Data(lines.utf8)) - XCTAssertEqual(events.count, 1) - XCTAssertEqual(events[0].input, 100) - XCTAssertEqual(events[0].output, 20) + XCTAssertEqual(events.map { [$0.input, $0.output] }, [[100, 20]]) } func testForkSessionReplayIsSkippedToo() { @@ -384,48 +325,30 @@ final class CodexLogUsageScannerTests: XCTestCase { XCTAssertEqual(events[0].output, 20) } - func testRootFileKeepsAllLines() { - // A root session (no parent in its session_meta) skips nothing, even when lines share a - // second and unrelated content mentions "thread_spawn". - let lines = [ - CodexLogFixture.rootSessionMeta(timestamp: "2026-05-12T08:03:00.000Z"), - #"{"timestamp":"2026-05-12T08:03:00.000Z","type":"event_msg","payload":{"type":"agent_message","message":"about thread_spawn"}}"#, - CodexLogFixture.tokenCount( - timestamp: "2026-05-12T08:03:00.000Z", - last: CodexLogFixture.usage(input: 100, output: 20) - ), - CodexLogFixture.tokenCount( - timestamp: "2026-05-12T08:03:00.500Z", - last: CodexLogFixture.usage(input: 50, output: 10) - ) - ].joined(separator: "\n") - - XCTAssertEqual(CodexLogUsageScanner.parseFile(Data(lines.utf8)).count, 2) - } + func testRootSessionsKeepUsageWithMissingOrNullParentMetadata() { + let cases: [(name: String, prefix: [String])] = [ + ("root metadata and unrelated spawn mention", [ + CodexLogFixture.rootSessionMeta(timestamp: "2026-05-12T08:03:00.000Z"), + #"{"timestamp":"2026-05-12T08:03:00.000Z","type":"event_msg","payload":{"type":"agent_message","message":"about thread_spawn"}}"# + ]), + ("null parent fields", [ + #"{"timestamp":"2026-05-12T08:03:00.000Z","type":"session_meta","payload":{"id":"root-abc","forked_from_id":null,"parent_thread_id":null,"source":{"subagent":null}}}"# + ]), + ("missing session metadata", []) + ] - func testRootSessionMetaWithNullParentFieldsIsNotTreatedAsChild() { - // JSONSerialization represents JSON null as NSNull (not Swift nil). A root session that - // declares forked_from_id / parent_thread_id / source.subagent as null must keep all lines. - let sessionMeta = #"{"timestamp":"2026-05-12T08:03:00.000Z","type":"session_meta","payload":{"id":"root-abc","forked_from_id":null,"parent_thread_id":null,"source":{"subagent":null}}}"# - let lines = [ - sessionMeta, - CodexLogFixture.tokenCount( - timestamp: "2026-05-12T08:03:00.100Z", - last: CodexLogFixture.usage(input: 100, output: 20) - ), - CodexLogFixture.tokenCount( - timestamp: "2026-05-12T08:03:00.500Z", - last: CodexLogFixture.usage(input: 50, output: 10) - ) - ].joined(separator: "\n") + for entry in cases { + let lines = (entry.prefix + [ + CodexLogFixture.tokenCount( + timestamp: "2026-05-12T08:03:00.100Z", last: CodexLogFixture.usage(input: 100, output: 20) + ), + CodexLogFixture.tokenCount( + timestamp: "2026-05-12T08:03:00.500Z", last: CodexLogFixture.usage(input: 50, output: 10) + ) + ]).joined(separator: "\n") - XCTAssertEqual(CodexLogUsageScanner.parseFile(Data(lines.utf8)).map(\.input), [100, 50]) - XCTAssertFalse(CodexLogUsageScanner.isChildSessionMeta([ - "id": "root-abc", - "forked_from_id": NSNull(), - "parent_thread_id": NSNull(), - "source": ["subagent": NSNull()] - ])) + XCTAssertEqual(CodexLogUsageScanner.parseFile(Data(lines.utf8)).map(\.input), [100, 50], entry.name) + } } func testChildSessionMetaWithoutTimestampStillSkipsReplay() { @@ -450,22 +373,6 @@ final class CodexLogUsageScannerTests: XCTestCase { XCTAssertEqual(CodexLogUsageScanner.parseFile(Data(lines.utf8)).map(\.input), [50]) } - func testFileWithoutSessionMetaKeepsAllLines() { - // Older fixtures / truncated files with no session_meta at all: treat as a root session. - let lines = [ - CodexLogFixture.tokenCount( - timestamp: "2026-05-12T08:03:00.000Z", - last: CodexLogFixture.usage(input: 100, output: 20) - ), - CodexLogFixture.tokenCount( - timestamp: "2026-05-12T08:03:00.500Z", - last: CodexLogFixture.usage(input: 50, output: 10) - ) - ].joined(separator: "\n") - - XCTAssertEqual(CodexLogUsageScanner.parseFile(Data(lines.utf8)).count, 2) - } - func testUnchangedTotalsSnapshotIsSkippedEvenWithLastUsage() { // Codex re-emits stale token_count snapshots: same cumulative totals, repeated // last_token_usage, new timestamp. Only the first counts. @@ -543,36 +450,21 @@ final class CodexLogUsageScannerTests: XCTestCase { ) } - func testAggregateFeedsSingleModelTodayBreakdown() throws { + func testAggregatedModelUsageReachesTodaySpendBreakdown() throws { let now = Date() - let event = CodexLogUsageScanner.Event( - timestamp: now, - model: "gpt-5.2", - input: 100, - cached: 0, - output: 50, - reasoning: 0, - total: 150 - ) let scan = CodexLogUsageScanner.aggregate( - events: [event], since: .distantPast, pricing: fixedRates() + events: [makeEvent(OpenUsageISO8601.string(from: now))], since: .distantPast, pricing: fixedRates() ) - var lines: [MetricLine] = [] SpendTileMapper.appendTokenUsage( - scan.series, - to: &lines, - now: now, - unknownModelsByDay: scan.unknownModelsByDay, - modelUsage: scan.modelUsage, - modelSourceNote: "From Codex test logs" + scan.series, to: &lines, now: now, modelUsage: scan.modelUsage, modelSourceNote: "From Codex test logs" ) guard case .values(_, _, _, _, _, let breakdown) = lines.first(where: { $0.label == "Today" }) else { return XCTFail("Expected a Today spend row") } - let today = try XCTUnwrap(breakdown) - XCTAssertEqual(today.models, [ModelUsageEntry(model: "gpt-5.2", totalTokens: 150, costUSD: 0.25)]) + XCTAssertEqual(try XCTUnwrap(breakdown).models, + [ModelUsageEntry(model: "gpt-5.2", totalTokens: 150, costUSD: 0.25)]) } func testAggregateDropsIdenticalEventsAcrossFiles() { diff --git a/Tests/OpenUsageTests/CodexProviderTests.swift b/Tests/OpenUsageTests/CodexProviderTests.swift index 7bba3a0dc..bf723d5a2 100644 --- a/Tests/OpenUsageTests/CodexProviderTests.swift +++ b/Tests/OpenUsageTests/CodexProviderTests.swift @@ -11,55 +11,21 @@ final class CodexAuthStoreTests: XCTestCase { XCTAssertEqual(auth?.tokens?.accessToken, "token") } - // MARK: needsRefresh (issue #516 — refresh by JWT exp, not a hardcoded 8-day age) - - func testValidFutureExpAccessTokenDoesNotNeedRefresh() { - // A JWT whose `exp` is comfortably in the future must NOT trigger a proactive refresh, even - // when `last_refresh` is old/missing — the old 8-day rule refreshed a still-valid token and - // tripped refresh_token_reused. - let now = Date(timeIntervalSince1970: 1_800_000_000) - let store = CodexAuthStore(now: { now }) - let auth = CodexAuth( - tokens: CodexTokens(accessToken: jwt(exp: now.addingTimeInterval(60 * 60))), - lastRefresh: nil - ) - - XCTAssertFalse(store.needsRefresh(auth)) - } - - func testNearExpiryAccessTokenNeedsRefresh() { - // Within the 5-minute window of `exp` ⇒ refresh now. - let now = Date(timeIntervalSince1970: 1_800_000_000) - let store = CodexAuthStore(now: { now }) - let auth = CodexAuth( - tokens: CodexTokens(accessToken: jwt(exp: now.addingTimeInterval(60))), - lastRefresh: nil - ) - - XCTAssertTrue(store.needsRefresh(auth)) - } - - func testNoExpClaimFallsBackToStaleLastRefresh() { - // No decodable `exp` ⇒ fall back to the 8-day `last_refresh` rule; 9 days old ⇒ refresh. + func testRefreshDecisionUsesTokenExpiryThenLastRefresh() { let now = Date(timeIntervalSince1970: 1_800_000_000) let store = CodexAuthStore(now: { now }) let nineDaysAgo = OpenUsageISO8601.string(from: now.addingTimeInterval(-9 * 24 * 60 * 60)) - let auth = CodexAuth( - tokens: CodexTokens(accessToken: "token"), - lastRefresh: nineDaysAgo - ) - - XCTAssertTrue(store.needsRefresh(auth)) - } - - func testNoExpClaimAndNoLastRefreshDoesNotForceRefresh() { - // A brand-new login (no readable `exp`, no `last_refresh`) must NOT be forced to refresh — the - // old code returned true here and refreshed immediately on first launch. - let now = Date(timeIntervalSince1970: 1_800_000_000) - let store = CodexAuthStore(now: { now }) - let auth = CodexAuth(tokens: CodexTokens(accessToken: "token"), lastRefresh: nil) - - XCTAssertFalse(store.needsRefresh(auth)) + let cases: [(name: String, token: String, lastRefresh: String?, expected: Bool)] = [ + ("valid JWT", jwt(exp: now.addingTimeInterval(3600)), nil, false), + ("JWT near expiry", jwt(exp: now.addingTimeInterval(60)), nil, true), + ("stale refresh without JWT expiry", "token", nineDaysAgo, true), + ("new login without expiry or refresh", "token", nil, false) + ] + + for entry in cases { + let auth = CodexAuth(tokens: CodexTokens(accessToken: entry.token), lastRefresh: entry.lastRefresh) + XCTAssertEqual(store.needsRefresh(auth), entry.expected, entry.name) + } } /// Builds a real JWT-shaped token: `base64url(header).base64url({"exp":}).sig`. @@ -93,26 +59,7 @@ final class CodexAuthStoreTests: XCTestCase { } final class CodexUsageMapperTests: XCTestCase { - func testFreshSessionWindowPreservesReportedOnePercent() throws { - let now = Date(timeIntervalSince1970: 1_800_000_000) - let body = Data(""" - { - "rate_limit": { - "primary_window": { - "used_percent": 1, - "limit_window_seconds": 18000, - "reset_after_seconds": 18000, - "reset_at": \(Int(now.timeIntervalSince1970) + 18000) - } - } - } - """.utf8) - let response = HTTPResponse(statusCode: 200, headers: [:], body: body) - let mapped = try CodexUsageMapper.mapUsageResponse(response, now: now) - XCTAssertEqual(progress(mapped.lines, "Session")?.used, 1) - } - - func testFreshSessionWindowUsesDefaultPeriodWhenLimitWindowIsMissing() throws { + func testFreshSessionWindowPreservesOnePercentAndDefaultsMissingPeriod() throws { let now = Date(timeIntervalSince1970: 1_800_000_000) let resetAfterSeconds = CodexUsageMapper.sessionPeriodMs / 1000 let body = Data(""" @@ -400,57 +347,6 @@ final class CodexUsageMapperTests: XCTestCase { XCTAssertNil(progress(mapped.lines, "Some Other Model")) } - func testAppendsTokenUsageLines() { - var lines: [MetricLine] = [] - let usage = DailyUsageSeries(daily: [ - DailyUsageEntry(date: "2026-02-20", totalTokens: 150, costUSD: 0.75), - DailyUsageEntry(date: "2026-02-01", totalTokens: 300, costUSD: 1.0) - ]) - - SpendTileMapper.appendTokenUsage( - usage, - to: &lines, - now: makeDate("2026-02-20T16:00:00.000Z") - ) - - XCTAssertEqual(values(lines, "Today"), - [MetricValue(number: 0.75, kind: .dollars, estimated: true), - MetricValue(number: 150, kind: .count, label: "tokens")]) - // No usage yesterday → "No data" (no backing line), not a fabricated "$0.00 · 0 tokens". - XCTAssertNil(values(lines, "Yesterday")) - XCTAssertEqual(values(lines, "Last 30 Days"), - [MetricValue(number: 1.75, kind: .dollars, estimated: true), - MetricValue(number: 450, kind: .count, label: "tokens")]) - } - - func testZeroUsageLeavesTilesUnbacked() { - // A period with no usage is "No data" — no tile is appended, never a fabricated "$0.00 · 0 tokens". - // Fixed once in SpendTileMapper, so it holds for every provider that funnels through it. Here the - // only reported day is a zero-token Yesterday; Today is absent, Yesterday is idle, and the 30-day - // total is zero, so nothing is appended. - var lines: [MetricLine] = [] - SpendTileMapper.appendTokenUsage( - DailyUsageSeries(daily: [DailyUsageEntry(date: "2026-02-19", totalTokens: 0, costUSD: nil)]), - to: &lines, - now: makeDate("2026-02-20T16:00:00.000Z") - ) - - XCTAssertTrue(lines.isEmpty, "an all-zero window appends no spend tiles") - } - - func testUnpricedTokensShowTokensWithoutAFabricatedZeroDollar() { - // A day with real tokens the runner couldn't price omits the dollar — its cost is unknown, not - // zero — so the row shows just the labeled token count rather than a misleading "$0.00 ·". - var lines: [MetricLine] = [] - SpendTileMapper.appendTokenUsage( - DailyUsageSeries(daily: [DailyUsageEntry(date: "2026-02-20", totalTokens: 1_200_000, costUSD: nil)]), - to: &lines, - now: makeDate("2026-02-20T16:00:00.000Z") - ) - - XCTAssertEqual(values(lines, "Today"), [MetricValue(number: 1_200_000, kind: .count, label: "tokens")]) - } - // Regression: dollar amounts must group thousands (e.g. "$1,200.00") consistently with the // headline, which formats through `Formatters.currency`. Credit lines previously used a bare // `$%.2f` that dropped the separator. @@ -479,13 +375,7 @@ final class CodexUsageMapperTests: XCTestCase { XCTAssertEqual(values(mapped.lines, "Rate Limit Resets"), [MetricValue(number: 1, kind: .count, label: "available")]) - let resetIndex = mapped.lines.firstIndex { $0.label == "Rate Limit Resets" } - let creditsIndex = mapped.lines.firstIndex { $0.label == "Credits" } - XCTAssertNotNil(resetIndex) - XCTAssertNotNil(creditsIndex) - if let resetIndex, let creditsIndex { - XCTAssertLessThan(resetIndex, creditsIndex) - } + XCTAssertEqual(mapped.lines.map(\.label), ["Rate Limit Resets", "Credits"]) } func testShowsZeroRateLimitResets() throws { @@ -501,124 +391,55 @@ final class CodexUsageMapperTests: XCTestCase { [MetricValue(number: 0, kind: .count, label: "available")]) } - func testDedicatedEndpointSuppliesCountAndSortedExpiries() throws { - // The dedicated endpoint carries the per-credit expiry list the usage body lacks, so the count - // comes from it and `expiriesAt` holds every still-available credit's expiry, sorted soonest - // first. A non-"available" credit (the "consumed" one here) is excluded entirely. + func testDedicatedEndpointSortsAvailableExpiriesWithExplicitOrMissingStatus() throws { let usage = HTTPResponse(statusCode: 200, headers: [:], body: Data("{}".utf8)) - let resetCredits = HTTPResponse(statusCode: 200, headers: [:], body: Data(""" - { - "available_count": 2, - "credits": [ - { "status": "available", "expires_at": "2026-02-20T19:00:00.000Z" }, - { "status": "available", "expires_at": "2026-02-20T17:30:00.000Z" }, - { "status": "consumed", "expires_at": "2026-02-20T16:10:00.000Z" } - ] - } - """.utf8)) - - let mapped = try CodexUsageMapper.mapUsageResponse( - usage, - resetCredits: resetCredits, - now: OpenUsageISO8601.date(from: "2026-02-20T16:00:00.000Z")! - ) - - guard case .values(_, let vals, _, let expiriesAt, _, _) = mapped.lines.first(where: { $0.label == "Rate Limit Resets" }) else { - return XCTFail("expected a Rate Limit Resets values line") - } - XCTAssertEqual(vals, [MetricValue(number: 2, kind: .count, label: "available")]) - XCTAssertEqual(expiriesAt, [ + let expectedExpiries = [ OpenUsageISO8601.date(from: "2026-02-20T17:30:00.000Z")!, OpenUsageISO8601.date(from: "2026-02-20T19:00:00.000Z")! - ]) - } - - func testExpiriesPreservedWhenStatusOmitted() throws { - // `status` is optional upstream — a credit with `expires_at` but no `status` must still count - // toward the expiry list (otherwise the tooltip and the 24h warning vanish for that response - // shape). An explicitly non-available credit is still dropped. (Regression for the Codex-flagged - // "preserve expiries when status is omitted".) - let usage = HTTPResponse(statusCode: 200, headers: [:], body: Data("{}".utf8)) - let resetCredits = HTTPResponse(statusCode: 200, headers: [:], body: Data(""" - { - "available_count": 2, - "credits": [ - { "expires_at": "2026-02-20T19:00:00.000Z" }, - { "expires_at": "2026-02-20T17:30:00.000Z" }, - { "status": "consumed", "expires_at": "2026-02-20T16:10:00.000Z" } - ] - } - """.utf8)) - - let mapped = try CodexUsageMapper.mapUsageResponse( - usage, - resetCredits: resetCredits, - now: OpenUsageISO8601.date(from: "2026-02-20T16:00:00.000Z")! - ) - - guard case .values(_, _, _, let expiriesAt, _, _) = mapped.lines.first(where: { $0.label == "Rate Limit Resets" }) else { - return XCTFail("expected a Rate Limit Resets values line") + ] + + for status in ["available", nil] as [String?] { + let statusField = status.map { "\"status\":\"\($0)\"," } ?? "" + let resetCredits = HTTPResponse(statusCode: 200, headers: [:], body: Data(""" + {"available_count":2,"credits":[ + {\(statusField)"expires_at":"2026-02-20T19:00:00.000Z"}, + {\(statusField)"expires_at":"2026-02-20T17:30:00.000Z"}, + {"status":"consumed","expires_at":"2026-02-20T16:10:00.000Z"} + ]} + """.utf8)) + let mapped = try CodexUsageMapper.mapUsageResponse( + usage, + resetCredits: resetCredits, + now: OpenUsageISO8601.date(from: "2026-02-20T16:00:00.000Z")! + ) + + guard case .values(_, let values, _, let expiriesAt, _, _) = mapped.lines.first else { + return XCTFail("expected reset credits for status \(status ?? "omitted")") + } + XCTAssertEqual(values, [MetricValue(number: 2, kind: .count, label: "available")]) + XCTAssertEqual(expiriesAt, expectedExpiries, status ?? "omitted") } - // The two status-less credits are kept (sorted); the "consumed" one is dropped. - XCTAssertEqual(expiriesAt, [ - OpenUsageISO8601.date(from: "2026-02-20T17:30:00.000Z")!, - OpenUsageISO8601.date(from: "2026-02-20T19:00:00.000Z")! - ]) } - func testFallsBackToUsageBodyCountWhenDedicatedFetchUnavailable() throws { - // No dedicated response (the fetch failed): the count falls back to the usage body's embedded - // object, and with no expiry list `expiriesAt` is empty. + func testInvalidDedicatedResponsesFallBackToUsageBodyCount() throws { let usage = HTTPResponse(statusCode: 200, headers: [:], body: Data(#"{ "rate_limit_reset_credits": { "available_count": 3 } }"#.utf8)) - - let mapped = try CodexUsageMapper.mapUsageResponse( - usage, - resetCredits: nil, - now: Date(timeIntervalSince1970: 1_800_000_000) - ) - - guard case .values(_, let vals, _, let expiriesAt, _, _) = mapped.lines.first(where: { $0.label == "Rate Limit Resets" }) else { - return XCTFail("expected a Rate Limit Resets values line") + let cases: [(name: String, response: HTTPResponse?)] = [ + ("missing", nil), + ("null count", HTTPResponse(statusCode: 200, headers: [:], body: Data(#"{"available_count":null}"#.utf8))), + ("server failure", HTTPResponse(statusCode: 500, headers: [:], body: Data("oops".utf8))) + ] + + for entry in cases { + let mapped = try CodexUsageMapper.mapUsageResponse( + usage, resetCredits: entry.response, now: Date(timeIntervalSince1970: 1_800_000_000) + ) + guard case .values(_, let values, _, let expiriesAt, _, _) = mapped.lines.first else { + return XCTFail("expected reset credits when dedicated response is \(entry.name)") + } + XCTAssertEqual(values, [MetricValue(number: 3, kind: .count, label: "available")], entry.name) + XCTAssertTrue(expiriesAt.isEmpty, entry.name) } - XCTAssertEqual(vals, [MetricValue(number: 3, kind: .count, label: "available")]) - XCTAssertTrue(expiriesAt.isEmpty) - } - - func testDedicatedNullCountFallsBackToUsageBodyCount() throws { - // A 2xx dedicated payload whose `available_count` is JSON null (NSNull, which is non-nil) must NOT - // be selected as the source — doing so would drop the whole row. It falls back to the usage body's - // valid embedded count instead. (Regression for the bot-flagged NSNull nil-check.) - let usage = HTTPResponse(statusCode: 200, headers: [:], - body: Data(#"{ "rate_limit_reset_credits": { "available_count": 2 } }"#.utf8)) - let resetCredits = HTTPResponse(statusCode: 200, headers: [:], - body: Data(#"{ "available_count": null }"#.utf8)) - - let mapped = try CodexUsageMapper.mapUsageResponse( - usage, - resetCredits: resetCredits, - now: Date(timeIntervalSince1970: 1_800_000_000) - ) - - XCTAssertEqual(values(mapped.lines, "Rate Limit Resets"), - [MetricValue(number: 2, kind: .count, label: "available")]) - } - - func testDedicatedNon2xxFallsBackToUsageBodyCount() throws { - // A non-2xx dedicated response is ignored (treated as unavailable), so the count falls back to - // the usage body — never a dropped row just because the extra endpoint erred. - let usage = HTTPResponse(statusCode: 200, headers: [:], - body: Data(#"{ "rate_limit_reset_credits": { "available_count": 1 } }"#.utf8)) - let resetCredits = HTTPResponse(statusCode: 500, headers: [:], body: Data("oops".utf8)) - - let mapped = try CodexUsageMapper.mapUsageResponse( - usage, - resetCredits: resetCredits, - now: Date(timeIntervalSince1970: 1_800_000_000) - ) - - XCTAssertEqual(values(mapped.lines, "Rate Limit Resets"), - [MetricValue(number: 1, kind: .count, label: "available")]) } func testOmitsRateLimitResetsWhenCountMalformed() throws { @@ -647,9 +468,6 @@ final class CodexUsageMapperTests: XCTestCase { return values } - private func makeDate(_ value: String) -> Date { - OpenUsageISO8601.date(from: value)! - } } @MainActor @@ -732,31 +550,17 @@ final class CodexUsageClientRefreshTests: XCTestCase { ) } - func testRefreshReportsRequestFailureForUnrecognizedErrorBody() async { - // A 400 carrying a non-OAuth body (an HTML proxy/WAF page) must surface as a request failure, - // not "Token expired. Run `codex` to log in again." — re-login can't fix a transport/infra error. - let http = FakeHTTPClient(response: HTTPResponse(statusCode: 400, headers: [:], body: Data("Bad Gateway".utf8))) - let client = CodexUsageClient(http: http) - do { - _ = try await client.refreshToken("refresh") - XCTFail("expected refreshToken to throw") - } catch let error as CodexUsageError { - XCTAssertEqual(error, .requestFailed(400)) - } catch { - XCTFail("expected CodexUsageError.requestFailed, got \(error)") - } - } - - func testRefreshReportsRequestFailureForNon4xxStatus() async { - let http = FakeHTTPClient(response: HTTPResponse(statusCode: 503, headers: [:], body: Data())) - let client = CodexUsageClient(http: http) - do { - _ = try await client.refreshToken("refresh") - XCTFail("expected refreshToken to throw") - } catch let error as CodexUsageError { - XCTAssertEqual(error, .requestFailed(503)) - } catch { - XCTFail("expected CodexUsageError.requestFailed, got \(error)") + func testRefreshReportsRequestFailuresForUnrecognizedAndServerErrors() async { + for (status, body) in [(400, "Bad Gateway"), (503, "")] { + let http = FakeHTTPClient(response: HTTPResponse(statusCode: status, headers: [:], body: Data(body.utf8))) + do { + _ = try await CodexUsageClient(http: http).refreshToken("refresh") + XCTFail("expected status \(status) to throw") + } catch let error as CodexUsageError { + XCTAssertEqual(error, .requestFailed(status)) + } catch { + XCTFail("expected CodexUsageError.requestFailed, got \(error)") + } } } diff --git a/Tests/OpenUsageTests/CopilotProviderTests.swift b/Tests/OpenUsageTests/CopilotProviderTests.swift index 5d41802ee..79a6a6533 100644 --- a/Tests/OpenUsageTests/CopilotProviderTests.swift +++ b/Tests/OpenUsageTests/CopilotProviderTests.swift @@ -2,48 +2,6 @@ import XCTest @testable import OpenUsage final class CopilotAuthStoreTests: XCTestCase { - func testReadsEditorAppsJSON() { - let store = CopilotAuthStore( - files: FakeFiles([ - CopilotAuthStore.editorAppsPath: """ - { "github.com:Iv1.abc123": { "user": "octocat", "oauth_token": "gho_editor" } } - """ - ]), - keychain: FakeKeychain() - ) - - let token = store.loadToken() - - XCTAssertEqual(token?.value, "gho_editor") - } - - func testReadsGhHostsOAuthToken() { - let store = CopilotAuthStore( - files: FakeFiles([ - CopilotAuthStore.ghHostsPath: """ - github.com: - git_protocol: https - user: octocat - oauth_token: gho_ghconfig - """ - ]), - keychain: FakeKeychain() - ) - - let token = store.loadToken() - - XCTAssertEqual(token?.value, "gho_ghconfig") - } - - func testDecodesGoKeyringWrappedGhKeychainToken() { - let wrapped = "go-keyring-base64:" + Data("gho_keychain".utf8).base64EncodedString() - let store = CopilotAuthStore(files: FakeFiles(), keychain: FakeKeychain(wrapped)) - - let token = store.loadToken() - - XCTAssertEqual(token?.value, "gho_keychain") - } - func testEditorConfigWinsOverKeychain() { let store = CopilotAuthStore( files: FakeFiles([ @@ -87,8 +45,10 @@ final class CopilotAuthStoreTests: XCTestCase { XCTAssertEqual(store.loadToken()?.value, "gho_dotcom") } - func testYamlValueIgnoresNestedUsersMap() { + func testYamlUserIsScopedToGithubAndIgnoresNestedUsersMap() { let hosts = """ + ghe.corp.example: + user: enterprise github.com: users: octocat: @@ -97,20 +57,6 @@ final class CopilotAuthStoreTests: XCTestCase { XCTAssertEqual(CopilotAuthStore.yamlValue(hosts, key: "user"), "octocat") } - func testYamlValueScopesToGithubDotComHost() { - // A GitHub Enterprise block precedes github.com; the github.com token must win. - let hosts = """ - ghe.corp.example: - oauth_token: gho_enterprise - user: ent - github.com: - oauth_token: gho_dotcom - user: octocat - """ - XCTAssertEqual(CopilotAuthStore.yamlValue(hosts, key: "oauth_token"), "gho_dotcom") - XCTAssertEqual(CopilotAuthStore.yamlValue(hosts, key: "user"), "octocat") - } - func testGhConfigPrefersGithubDotComTokenOverEnterprise() { let store = CopilotAuthStore( files: FakeFiles([ @@ -137,6 +83,7 @@ final class CopilotUsageMapperTests: XCTestCase { XCTAssertEqual(progress(mapped.lines, "Chat")?.used, 5) XCTAssertNotNil(progress(mapped.lines, "Credits")?.resetsAt) XCTAssertEqual(progress(mapped.lines, "Credits")?.periodDurationMs, CopilotUsageMapper.periodMs) + XCTAssertNil(mapped.lines.first(where: { $0.label == "Extra Usage" })) } func testSuppressesUnlimitedAndSentinelBuckets() throws { @@ -156,38 +103,21 @@ final class CopilotUsageMapperTests: XCTestCase { XCTAssertEqual(progress(mapped.lines, "Credits")?.used, 59) } - func testEmitsExtraUsageWhenOveragePermitted() throws { - var body = makePaidBody() - var quota = body["quota_snapshots"] as! [String: Any] - var premium = quota["premium_interactions"] as! [String: Any] - premium["overage_permitted"] = true - premium["overage_count"] = 36 - quota["premium_interactions"] = premium - body["quota_snapshots"] = quota - - let mapped = try CopilotUsageMapper.map(body: body) - - XCTAssertEqual(countValue(mapped.lines, "Extra Usage"), 36) - } - - func testShowsExtraUsageZeroWhenPermittedButUnused() throws { - var body = makePaidBody() - var quota = body["quota_snapshots"] as! [String: Any] - var premium = quota["premium_interactions"] as! [String: Any] - premium["overage_permitted"] = true - premium["overage_count"] = 0 - quota["premium_interactions"] = premium - body["quota_snapshots"] = quota - - let mapped = try CopilotUsageMapper.map(body: body) + func testPermittedExtraUsagePreservesPositiveAndZeroCounts() throws { + for overage in [36, 0] { + var body = makePaidBody() + var quota = body["quota_snapshots"] as! [String: Any] + var premium = quota["premium_interactions"] as! [String: Any] + premium["overage_permitted"] = true + premium["overage_count"] = overage + quota["premium_interactions"] = premium + body["quota_snapshots"] = quota - XCTAssertEqual(countValue(mapped.lines, "Extra Usage"), 0) - } + let mapped = try CopilotUsageMapper.map(body: body) - func testSuppressesExtraUsageWhenNotPermitted() throws { - // makePaidBody's premium has no overage flag → extra usage is genuinely N/A. - let mapped = try CopilotUsageMapper.map(body: makePaidBody()) - XCTAssertNil(mapped.lines.first(where: { $0.label == "Extra Usage" })) + XCTAssertEqual(countValue(mapped.lines, "Extra Usage"), Double(overage)) + XCTAssertFalse(mapped.isOrgManagedSeat) + } } func testIgnoresLegacyLimitedQuotasWhenSnapshotsPresent() throws { @@ -265,20 +195,22 @@ final class CopilotUsageMapperTests: XCTestCase { XCTAssertNotNil(progress(mapped.lines, "Chat")?.resetsAt) } - func testTokenBasedBillingReturnsPlanWithoutMeters() throws { - let body: [String: Any] = [ - "copilot_plan": "business", - "token_based_billing": true, - "quota_snapshots": [ - "premium_interactions": ["entitlement": 0, "remaining": 0, "quota_id": "premium"] + func testUnusedOrgManagedSeatPreservesPlanWithoutMeters() throws { + for creditsUsed in [nil, 0] as [Int?] { + var premium: [String: Any] = ["entitlement": 0, "remaining": 0] + if let creditsUsed { premium["credits_used"] = creditsUsed } + let body: [String: Any] = [ + "copilot_plan": "business", + "token_based_billing": true, + "quota_snapshots": ["premium_interactions": premium] ] - ] - let mapped = try CopilotUsageMapper.map(body: body) + let mapped = try CopilotUsageMapper.map(body: body) - XCTAssertEqual(mapped.plan, "Business") - XCTAssertTrue(mapped.lines.isEmpty) - XCTAssertTrue(mapped.isOrgManagedSeat) + XCTAssertEqual(mapped.plan, "Business") + XCTAssertTrue(mapped.lines.isEmpty) + XCTAssertTrue(mapped.isOrgManagedSeat) + } } func testPlaceholderOveragePermittedDoesNotEmitExtraUsageOrBlockOrgFlag() throws { @@ -286,55 +218,16 @@ final class CopilotUsageMapperTests: XCTestCase { // `overage_permitted: true` on a zero-entitlement premium bucket. That must not render a // meaningless "Extra Usage: 0" row — and must still flag the seat as org-managed so the // provider runs the org-billing lookup. - var body: [String: Any] = [ - "copilot_plan": "business", - "token_based_billing": true, - "quota_snapshots": [ - "premium_interactions": [ - "entitlement": 0, "remaining": 0, "unlimited": true, - "overage_permitted": true, "overage_count": 0, "token_based_billing": true - ] - ] - ] - - let mapped = try CopilotUsageMapper.map(body: body) + let mapped = try CopilotUsageMapper.map(body: makeBusinessPlaceholderBody()) - XCTAssertNil(mapped.lines.first(where: { $0.label == "Extra Usage" })) XCTAssertTrue(mapped.lines.isEmpty) XCTAssertTrue(mapped.isOrgManagedSeat) - - // A paid account with a real credit pool keeps its Extra Usage row. - body = makePaidBody() - var quota = body["quota_snapshots"] as! [String: Any] - var premium = quota["premium_interactions"] as! [String: Any] - premium["overage_permitted"] = true - premium["overage_count"] = 12 - quota["premium_interactions"] = premium - body["quota_snapshots"] = quota - - let paid = try CopilotUsageMapper.map(body: body) - - XCTAssertEqual(countValue(paid.lines, "Extra Usage"), 12) - XCTAssertFalse(paid.isOrgManagedSeat) } func testShowsPersonalCreditsUsedOnOrgManagedPlaceholder() throws { // The exact shape reported in issue #1094: org-managed seat, zero entitlement, but // `premium_interactions.credits_used` carries the user's own real per-seat consumption. - let body: [String: Any] = [ - "copilot_plan": "business", - "token_based_billing": true, - "quota_snapshots": [ - "chat": ["unlimited": true, "token_based_billing": true, "credits_used": 0, "entitlement": 0, "percent_remaining": 100.0], - "completions": ["unlimited": true, "token_based_billing": true, "credits_used": 0, "entitlement": 0, "percent_remaining": 100.0], - "premium_interactions": [ - "unlimited": true, "token_based_billing": true, "credits_used": 2111, "entitlement": 0, - "overage_permitted": true, "percent_remaining": 100.0 - ] - ] - ] - - let mapped = try CopilotUsageMapper.map(body: body) + let mapped = try CopilotUsageMapper.map(body: makeBusinessPlaceholderBodyWithPersonalCredits(2111)) XCTAssertEqual(mapped.plan, "Business") XCTAssertEqual(countValue(mapped.lines, "Credits"), 2111) @@ -342,23 +235,6 @@ final class CopilotUsageMapperTests: XCTestCase { XCTAssertTrue(mapped.isOrgManagedSeat) } - func testUnusedOrgManagedSeatStillShowsNoData() throws { - // A genuinely unused seat (`credits_used` 0 or absent) must not regress to showing "0" — it - // stays empty, same as `testTokenBasedBillingReturnsPlanWithoutMeters`. - let body: [String: Any] = [ - "copilot_plan": "business", - "token_based_billing": true, - "quota_snapshots": [ - "premium_interactions": ["entitlement": 0, "remaining": 0, "credits_used": 0] - ] - ] - - let mapped = try CopilotUsageMapper.map(body: body) - - XCTAssertTrue(mapped.lines.isEmpty) - XCTAssertTrue(mapped.isOrgManagedSeat) - } - func testThrowsQuotaUnavailableWhenEmpty() { XCTAssertThrowsError(try CopilotUsageMapper.map(body: ["copilot_plan": "pro"])) { error in XCTAssertEqual(error as? CopilotUsageError, .quotaUnavailable) @@ -457,44 +333,6 @@ final class CopilotProviderTests: XCTestCase { XCTAssertEqual(http.requests.first?.headers["Authorization"], "token gho_editor") } - func testTokenBasedBillingShowsPlanWithoutError() async { - let body: [String: Any] = [ - "copilot_plan": "business", - "token_based_billing": true, - "quota_snapshots": ["premium_interactions": ["entitlement": 0, "remaining": 0]] - ] - let provider = CopilotProvider( - authStore: editorTokenStore(), - usageClient: CopilotUsageClient(http: FakeHTTPClient(response: ok(body))) - ) - - let snapshot = await provider.refresh() - - XCTAssertNil(snapshot.errorCategory) - XCTAssertEqual(snapshot.plan, "Business") - XCTAssertTrue(snapshot.lines.isEmpty) - } - - func testOrgManagedSeatShowsOrgBillingLines() async { - let http = routedClient([ - ("/copilot_internal/user", ok(makeBusinessPlaceholderBody())), - ("/user/orgs", okJSON([["login": "acme"]])), - ("/orgs/acme/settings/billing/usage/summary", ok(makeOrgSummaryBody())) - ]) - let defaults = freshDefaults() - let provider = makeOrgProvider(http: http, defaults: defaults) - - let snapshot = await provider.refresh() - - XCTAssertNil(snapshot.errorCategory) - XCTAssertEqual(snapshot.plan, "Business") - XCTAssertEqual(orgCount(snapshot.lines, "Org Credits") ?? -1, 298.698546, accuracy: 0.0001) - XCTAssertEqual(orgDollars(snapshot.lines, "Org Spend"), 0) - // The placeholder's `overage_permitted: true` must not leave a meaningless Extra Usage row. - XCTAssertNil(snapshot.lines.first(where: { $0.label == "Extra Usage" })) - XCTAssertEqual(defaults.string(forKey: CopilotProvider.billingOrgDefaultsKey), "acme") - } - func testOrgBillingForbiddenKeepsPlanOnlyCard() async { // A plain org member (not owner/billing manager) gets 403 on org billing — the expected state, // which must keep today's plan-only card rather than erroring the provider. @@ -548,9 +386,13 @@ final class CopilotProviderTests: XCTestCase { let snapshot = await provider.refresh() + XCTAssertNil(snapshot.errorCategory) + XCTAssertEqual(snapshot.plan, "Business") XCTAssertEqual(countValue(snapshot.lines, "Credits"), 2111) XCTAssertEqual(orgCount(snapshot.lines, "Org Credits") ?? -1, 298.698546, accuracy: 0.0001) + XCTAssertEqual(orgDollars(snapshot.lines, "Org Spend"), 0) XCTAssertNil(snapshot.lines.first(where: { $0.label == "Extra Usage" })) + XCTAssertEqual(defaults.string(forKey: CopilotProvider.billingOrgDefaultsKey), "acme") } func testUsesCachedOrgWithoutReprobing() async { @@ -676,21 +518,17 @@ private func routedClient(_ routes: [(substring: String, response: HTTPResponse) /// Crucially, the premium bucket carries `overage_permitted: true` — the field that used to sneak an /// "Extra Usage: 0" row into the mapped lines and block the org-billing fallback. private func makeBusinessPlaceholderBody() -> [String: Any] { - func bucket(_ id: String, overagePermitted: Bool) -> [String: Any] { - [ - "overage_count": 0, "overage_entitlement": 0, "overage_permitted": overagePermitted, - "percent_remaining": 100.0, "quota_id": id, "quota_remaining": 0.0, "unlimited": true, - "has_quota": true, "quota_reset_at": 0, "token_based_billing": true, - "remaining": 0, "entitlement": 0 - ] + func bucket(overagePermitted: Bool) -> [String: Any] { + ["entitlement": 0, "remaining": 0, "unlimited": true, + "overage_permitted": overagePermitted, "overage_count": 0] } return [ "copilot_plan": "business", "token_based_billing": true, "quota_snapshots": [ - "chat": bucket("chat", overagePermitted: false), - "completions": bucket("completions", overagePermitted: false), - "premium_interactions": bucket("premium_interactions", overagePermitted: true) + "chat": bucket(overagePermitted: false), + "completions": bucket(overagePermitted: false), + "premium_interactions": bucket(overagePermitted: true) ] ] } @@ -711,21 +549,9 @@ private func makeBusinessPlaceholderBodyWithPersonalCredits(_ creditsUsed: Doubl /// included credits. private func makeOrgSummaryBody() -> [String: Any] { [ - "timePeriod": ["year": 2026, "month": 7], - "organization": "acme", "usageItems": [ - [ - "product": "Copilot", - "sku": "copilot_ai_unit", - "unitType": "ai-units", - "pricePerUnit": 0.01, - "grossQuantity": 298.698546, - "grossAmount": 2.98698546, - "discountQuantity": 298.698546, - "discountAmount": 2.98698546, - "netQuantity": 0.0, - "netAmount": 0.0 - ] + ["product": "Copilot", "sku": "copilot_ai_unit", "unitType": "ai-units", + "grossQuantity": 298.698546, "netAmount": 0.0] ] ] } diff --git a/Tests/OpenUsageTests/CredentialCacheIntegrityTests.swift b/Tests/OpenUsageTests/CredentialCacheIntegrityTests.swift index 32254a977..b308f1814 100644 --- a/Tests/OpenUsageTests/CredentialCacheIntegrityTests.swift +++ b/Tests/OpenUsageTests/CredentialCacheIntegrityTests.swift @@ -182,31 +182,19 @@ final class AntigravityCredentialCacheIntegrityTests: XCTestCase { XCTAssertEqual(Set(authorizations), ["Bearer cached-access"]) } - func testMalformedStructuredKeychainValueIsNotSentAsBearerToken() async { - let http = RoutingHTTPClient { _ in - XCTFail("malformed structured credentials must not be sent") - return HTTPResponse(statusCode: 500, headers: [:], body: Data()) - } - let provider = makeProvider(keychain: FakeKeychain("{broken-json"), files: FakeFiles(), http: http) + func testMalformedStructuredKeychainValuesAreNeverSentAsBearerTokens() async { + for malformed in ["{broken-json", "\u{FEFF} \n\t{broken-json"] { + let http = RoutingHTTPClient { _ in + XCTFail("malformed structured credentials must not be sent") + return HTTPResponse(statusCode: 500, headers: [:], body: Data()) + } + let provider = makeProvider(keychain: FakeKeychain(malformed), files: FakeFiles(), http: http) - let snapshot = await provider.refresh() + let snapshot = await provider.refresh() - XCTAssertEqual(snapshot.errorCategory, .authInvalid) - XCTAssertTrue(http.requests.isEmpty) - } - - func testBOMPrefixedMalformedStructuredKeychainValueIsNotSentAsBearerToken() async { - let http = RoutingHTTPClient { _ in - XCTFail("BOM-prefixed malformed structured credentials must not be sent") - return HTTPResponse(statusCode: 500, headers: [:], body: Data()) + XCTAssertEqual(snapshot.errorCategory, .authInvalid, malformed) + XCTAssertTrue(http.requests.isEmpty, malformed) } - let malformed = "\u{FEFF} \n\t{broken-json" - let provider = makeProvider(keychain: FakeKeychain(malformed), files: FakeFiles(), http: http) - - let snapshot = await provider.refresh() - - XCTAssertEqual(snapshot.errorCategory, .authInvalid) - XCTAssertTrue(http.requests.isEmpty) } private func makeStore(files: TextFileAccessing) -> AntigravityAuthStore { diff --git a/Tests/OpenUsageTests/CursorSpendTests.swift b/Tests/OpenUsageTests/CursorSpendTests.swift index 0e274ad79..06183f898 100644 --- a/Tests/OpenUsageTests/CursorSpendTests.swift +++ b/Tests/OpenUsageTests/CursorSpendTests.swift @@ -101,30 +101,6 @@ final class CursorSpendRangeTests: XCTestCase { XCTAssertEqual(values(lines, "Yesterday"), [MetricValue(number: 2.00, kind: .dollars, estimated: true), MetricValue(number: 200, kind: .count, label: "tokens")]) // Last 30 Days sums every fetched day (the provider scopes the CSV to a 30-day window). XCTAssertEqual(values(lines, "Last 30 Days"), [MetricValue(number: 8.50, kind: .dollars, estimated: true), MetricValue(number: 1349, kind: .count, label: "tokens")]) - } - - func testZeroActivityLeavesTilesUnbacked() { - var lines: [MetricLine] = [] - CursorUsageMapper.appendSpendLines(rows: [], now: Date(), pricing: TestPricing.bundled, to: &lines) - - // The export fetched but had no rows: every period is idle, so no spend tile is appended and the - // tiles fall back to "No data" — not a fabricated "$0.00 · 0 tokens" ("No data" is also what a - // failed export produces; see the provider test). - XCTAssertNil(values(lines, "Today")) - XCTAssertNil(values(lines, "Yesterday")) - XCTAssertNil(values(lines, "Last 30 Days")) - } - - func testAppendSpendLinesAlsoAppendsUsageTrend() { - let now = Date(timeIntervalSince1970: 1_800_000_000) - let cal = Calendar.current - let rows = [ - makeRow(date: now, cost: 1.00, tokens: 100), // today - makeRow(date: cal.date(byAdding: .day, value: -1, to: now)!, cost: 2.00, tokens: 200) // yesterday - ] - - var lines: [MetricLine] = [] - CursorUsageMapper.appendSpendLines(rows: rows, now: now, pricing: TestPricing.bundled, to: &lines) guard case .chart(let label, let points, let note) = lines.first(where: { $0.label == "Usage Trend" }) else { return XCTFail("expected a Usage Trend chart line") @@ -137,12 +113,10 @@ final class CursorSpendRangeTests: XCTestCase { XCTAssertEqual(points[29].value, 200, "yesterday's tokens land on the second-to-last bar") } - func testNoRowsLeavesNoUsageTrend() { - // A fetched-but-empty export leaves the spend tiles unbacked and gives the trend nothing to draw, - // so no chart line is appended (the row falls back to "No data"). + func testEmptyExportLeavesSpendTilesAndUsageTrendUnbacked() { var lines: [MetricLine] = [] CursorUsageMapper.appendSpendLines(rows: [], now: Date(), pricing: TestPricing.bundled, to: &lines) - XCTAssertNil(lines.first(where: { $0.label == "Usage Trend" })) + XCTAssertTrue(lines.isEmpty) } func testUnknownModelsAttachToTheRightPeriods() { diff --git a/Tests/OpenUsageTests/FirstRunSeederTests.swift b/Tests/OpenUsageTests/FirstRunSeederTests.swift index 6c04989b8..ff14ab39e 100644 --- a/Tests/OpenUsageTests/FirstRunSeederTests.swift +++ b/Tests/OpenUsageTests/FirstRunSeederTests.swift @@ -152,7 +152,7 @@ final class FirstRunSeederTests: XCTestCase { // the assertion instead of detecting anything. let ids = ["claude", "codex", "cursor", "grok"] let gate = ProbeGate(expected: ids.count) - let providers = ids.map { GatedCredentialProvider(id: $0, gate: gate) } + let providers = ids.map { CredentialStubProvider(id: $0, gate: gate) } let detected = await FirstRunSeeder.detectLocalProviders(providers) @@ -192,38 +192,23 @@ final class FirstRunSeederTests: XCTestCase { private final class CredentialStubProvider: ProviderRuntime { let provider: Provider let widgetDescriptors: [WidgetDescriptor] = [] - private let hasCredentials: Bool + private let hasCredentials: @MainActor () async -> Bool init(id: String, hasCredentials: Bool) { self.provider = Provider(id: id, displayName: id.capitalized, icon: .providerMark(id)) - self.hasCredentials = hasCredentials + self.hasCredentials = { hasCredentials } } - func refresh() async -> ProviderSnapshot { - ProviderSnapshot.make(provider: provider, plan: nil, lines: [], refreshedAt: Date()) - } - - func hasLocalCredentials() async -> Bool { hasCredentials } -} - -/// A provider whose credential probe suspends on a shared `ProbeGate` until all expected probes have -/// started — "credentials found" therefore means "my probe overlapped every other probe". -@MainActor -private final class GatedCredentialProvider: ProviderRuntime { - let provider: Provider - let widgetDescriptors: [WidgetDescriptor] = [] - private let gate: ProbeGate - init(id: String, gate: ProbeGate) { self.provider = Provider(id: id, displayName: id.capitalized, icon: .providerMark(id)) - self.gate = gate + self.hasCredentials = { await gate.arrive() } } func refresh() async -> ProviderSnapshot { ProviderSnapshot.make(provider: provider, plan: nil, lines: [], refreshedAt: Date()) } - func hasLocalCredentials() async -> Bool { await gate.arrive() } + func hasLocalCredentials() async -> Bool { await hasCredentials() } } /// Suspends each arriver until `expected` arrivals are in flight, then resumes them all with `true`. diff --git a/Tests/OpenUsageTests/GrokProviderTests.swift b/Tests/OpenUsageTests/GrokProviderTests.swift index ea05660ae..2c18e16d6 100644 --- a/Tests/OpenUsageTests/GrokProviderTests.swift +++ b/Tests/OpenUsageTests/GrokProviderTests.swift @@ -158,37 +158,22 @@ final class GrokProviderTests: XCTestCase { XCTAssertNil(snapshot.warning) } - func testCreditsFetchFailureFailsTheProvider() async { - // The credits config is the provider's only remote meter now — its failure is a provider - // error, not a partial degrade. - let httpClient = RecordingHTTPClient { request in - if request.url == GrokUsageClient.creditsConfigURL { - return HTTPResponse(statusCode: 503, headers: [:], body: Data()) - } - return Self.defaultRoutes(request) - } - let provider = makeProvider(httpClient: httpClient) - - let snapshot = await provider.refresh() - - XCTAssertNotNil(snapshot.errorCategory) - XCTAssertNil(progress(snapshot.lines, "Weekly limit")) - } + func testCreditsTransportAndSchemaFailuresFailTheProvider() async { + let cases = [(503, ""), (200, #"{"config":{}}"#)] - func testMalformedBodyInsideHTTP200FailsTheProvider() async { - // An HTTP 200 whose body isn't the shape we know is schema drift — fail loudly rather than - // render a blank dashboard silently. - let httpClient = RecordingHTTPClient { request in - if request.url == GrokUsageClient.creditsConfigURL { - return HTTPResponse(statusCode: 200, headers: [:], body: Data(#"{"config":{}}"#.utf8)) + for (status, body) in cases { + let httpClient = RecordingHTTPClient { request in + if request.url == GrokUsageClient.creditsConfigURL { + return HTTPResponse(statusCode: status, headers: [:], body: Data(body.utf8)) + } + return Self.defaultRoutes(request) } - return Self.defaultRoutes(request) - } - let provider = makeProvider(httpClient: httpClient) - let snapshot = await provider.refresh() + let snapshot = await makeProvider(httpClient: httpClient).refresh() - XCTAssertNotNil(snapshot.errorCategory) + XCTAssertNotNil(snapshot.errorCategory) + XCTAssertNil(progress(snapshot.lines, "Weekly limit")) + } } func testNonWeeklyPeriodShowsNoWeeklyLineAndNoWarning() async { @@ -242,6 +227,14 @@ final class GrokProviderTests: XCTestCase { [MetricValue(number: 15.0, kind: .dollars, estimated: true), MetricValue(number: 1_000_000, kind: .count, label: "tokens")]) XCTAssertEqual(values(snapshot.lines, "Last 30 Days"), [MetricValue(number: 16.0, kind: .dollars, estimated: true), MetricValue(number: 2_000_000, kind: .count, label: "tokens")]) + + guard case .chart(_, let points, let note) = snapshot.lines.first(where: { $0.label == "Usage Trend" }) else { + return XCTFail("expected a Usage Trend chart line") + } + XCTAssertEqual(note, "From your Grok logs (estimated)") + XCTAssertEqual(points.count, 31) + XCTAssertEqual(points.last?.value, 1_000_000) + XCTAssertEqual(points[29].value, 1_000_000) } func testPeriodWithoutUsageLeavesTileUnbacked() async throws { @@ -271,32 +264,6 @@ final class GrokProviderTests: XCTestCase { XCTAssertNotNil(values(snapshot.lines, "Last 30 Days")) } - func testRefreshAppendsUsageTrendFromSessions() async throws { - let now = OpenUsageISO8601.date(from: "2026-06-18T12:00:00.000Z")! - let today = GrokLogFixture.completedTurn( - timestamp: "2026-06-18T10:00:00.000Z", model: "grok-build", input: 1_000_000 - ) - let yesterday = GrokLogFixture.completedTurn( - timestamp: "2026-06-17T10:00:00.000Z", model: "grok-composer-2.5-fast", input: 0, output: 1_000_000 - ) - let home = try GrokLogFixture.makeHome(files: [ - "project/session/updates.jsonl": [today, yesterday].joined(separator: "\n") - ]) - defer { try? FileManager.default.removeItem(at: home) } - let scanner = GrokLogFixture.scanner(home: home) - let provider = makeProvider(httpClient: RecordingHTTPClient(handler: Self.defaultRoutes), scanner: scanner, now: now) - - let snapshot = await provider.refresh() - - guard case .chart(_, let points, let note) = snapshot.lines.first(where: { $0.label == "Usage Trend" }) else { - return XCTFail("expected a Usage Trend chart line") - } - XCTAssertEqual(note, "From your Grok logs (estimated)") - XCTAssertEqual(points.count, 31) - XCTAssertEqual(points.last?.value, 1_000_000, "today's tokens land on the last bar") - XCTAssertEqual(points[29].value, 1_000_000, "yesterday's tokens land on the second-to-last bar") - } - func testRefreshWithoutSessionsAppendsNoUsageTrend() async { let provider = makeProvider(httpClient: RecordingHTTPClient(handler: Self.defaultRoutes)) diff --git a/Tests/OpenUsageTests/ICloudUsageSyncStoreTests.swift b/Tests/OpenUsageTests/ICloudUsageSyncStoreTests.swift index 9cc338b56..4377b4ef6 100644 --- a/Tests/OpenUsageTests/ICloudUsageSyncStoreTests.swift +++ b/Tests/OpenUsageTests/ICloudUsageSyncStoreTests.swift @@ -6,14 +6,7 @@ final class ICloudUsageSyncStoreTests: XCTestCase { func testEnableWritesLoadsAndDisableDeletesThisMac() async throws { let defaults = makeDefaults("enable-disable") let fileStore = RecordingHistoryFileStore() - let sync = ICloudUsageSyncStore( - dataStore: makeDataStore(defaults), - defaults: defaults, - fileStore: fileStore, - deviceIDStore: MemoryDeviceIDStore(), - writeDebounce: .milliseconds(10), - observesMetadataChanges: false - ) + let sync = makeSync(defaults: defaults, fileStore: fileStore, writeDebounce: .milliseconds(10)) sync.enabled = true try await waitUntil { await fileStore.writeCount == 1 && sync.displayedDocuments.count == 1 } @@ -29,14 +22,7 @@ final class ICloudUsageSyncStoreTests: XCTestCase { func testAdjacentHistoryChangesDebounceToOneWrite() async throws { let defaults = makeDefaults("debounce") let fileStore = RecordingHistoryFileStore() - let sync = ICloudUsageSyncStore( - dataStore: makeDataStore(defaults), - defaults: defaults, - fileStore: fileStore, - deviceIDStore: MemoryDeviceIDStore(), - writeDebounce: .milliseconds(20), - observesMetadataChanges: false - ) + let sync = makeSync(defaults: defaults, fileStore: fileStore, writeDebounce: .milliseconds(20)) sync.enabled = true try await waitUntil { await fileStore.writeCount == 1 } @@ -53,13 +39,7 @@ final class ICloudUsageSyncStoreTests: XCTestCase { func testDisableDeletesWriteThatWasAlreadyInFlight() async throws { let defaults = makeDefaults("disable-in-flight-write") let fileStore = RecordingHistoryFileStore() - let sync = ICloudUsageSyncStore( - dataStore: makeDataStore(defaults), - defaults: defaults, - fileStore: fileStore, - deviceIDStore: MemoryDeviceIDStore(), - observesMetadataChanges: false - ) + let sync = makeSync(defaults: defaults, fileStore: fileStore) // Hold the enable write open so disable can race it deliberately, instead of hoping an // 80ms sleep is still in flight when the test flips the toggle on a loaded CI runner. @@ -86,13 +66,7 @@ final class ICloudUsageSyncStoreTests: XCTestCase { func testUnavailableStoreSurfacesFriendlyError() async throws { let defaults = makeDefaults("unavailable") let fileStore = RecordingHistoryFileStore(unavailable: true) - let sync = ICloudUsageSyncStore( - dataStore: makeDataStore(defaults), - defaults: defaults, - fileStore: fileStore, - deviceIDStore: MemoryDeviceIDStore(), - observesMetadataChanges: false - ) + let sync = makeSync(defaults: defaults, fileStore: fileStore) sync.enabled = true try await waitUntil { sync.serviceError != nil && !sync.isSyncing } @@ -113,13 +87,7 @@ final class ICloudUsageSyncStoreTests: XCTestCase { seedDocuments: [peer], invalidFileMessages: ["broken.json: invalid value"] ) - let sync = ICloudUsageSyncStore( - dataStore: makeDataStore(defaults), - defaults: defaults, - fileStore: fileStore, - deviceIDStore: MemoryDeviceIDStore(), - observesMetadataChanges: false - ) + let sync = makeSync(defaults: defaults, fileStore: fileStore) sync.enabled = true try await waitUntil { sync.invalidFileMessages.count == 1 } @@ -131,14 +99,7 @@ final class ICloudUsageSyncStoreTests: XCTestCase { func testBackgroundReloadShowsSyncActivity() async throws { let defaults = makeDefaults("background-sync-activity") let fileStore = RecordingHistoryFileStore() - let sync = ICloudUsageSyncStore( - dataStore: makeDataStore(defaults), - defaults: defaults, - fileStore: fileStore, - deviceIDStore: MemoryDeviceIDStore(), - writeDebounce: .milliseconds(10), - observesMetadataChanges: false - ) + let sync = makeSync(defaults: defaults, fileStore: fileStore, writeDebounce: .milliseconds(10)) sync.enabled = true try await waitUntil { @@ -164,21 +125,9 @@ final class ICloudUsageSyncStoreTests: XCTestCase { firstDefaults.set(expectedID, forKey: "openusage.icloudSync.deviceID.v1") let deviceIDStore = MemoryDeviceIDStore() - let first = ICloudUsageSyncStore( - dataStore: makeDataStore(firstDefaults), - defaults: firstDefaults, - fileStore: RecordingHistoryFileStore(), - deviceIDStore: deviceIDStore, - observesMetadataChanges: false - ) + let first = makeSync(defaults: firstDefaults, fileStore: RecordingHistoryFileStore(), deviceIDStore: deviceIDStore) let resetDefaults = makeDefaults("identity-after-reset") - let afterReset = ICloudUsageSyncStore( - dataStore: makeDataStore(resetDefaults), - defaults: resetDefaults, - fileStore: RecordingHistoryFileStore(), - deviceIDStore: deviceIDStore, - observesMetadataChanges: false - ) + let afterReset = makeSync(defaults: resetDefaults, fileStore: RecordingHistoryFileStore(), deviceIDStore: deviceIDStore) XCTAssertEqual(first.deviceID, expectedID) XCTAssertEqual(afterReset.deviceID, expectedID) @@ -203,13 +152,26 @@ final class ICloudUsageSyncStoreTests: XCTestCase { XCTAssertEqual(try production.readDeviceID(), "production-id") } - private func makeDataStore(_ defaults: UserDefaults) -> WidgetDataStore { - WidgetDataStore( + private func makeSync( + defaults: UserDefaults, + fileStore: RecordingHistoryFileStore, + deviceIDStore: MemoryDeviceIDStore = MemoryDeviceIDStore(), + writeDebounce: Duration = .seconds(3) + ) -> ICloudUsageSyncStore { + let dataStore = WidgetDataStore( registry: WidgetRegistry(providers: [], descriptors: []), providers: [], cache: ProviderSnapshotCache(userDefaults: defaults, storageKey: "snapshots"), defaults: defaults ) + return ICloudUsageSyncStore( + dataStore: dataStore, + defaults: defaults, + fileStore: fileStore, + deviceIDStore: deviceIDStore, + writeDebounce: writeDebounce, + observesMetadataChanges: false + ) } private func makeDefaults(_ name: String) -> UserDefaults { diff --git a/Tests/OpenUsageTests/IncrementalJSONLScannerTests.swift b/Tests/OpenUsageTests/IncrementalJSONLScannerTests.swift index 22b905e10..8892783e2 100644 --- a/Tests/OpenUsageTests/IncrementalJSONLScannerTests.swift +++ b/Tests/OpenUsageTests/IncrementalJSONLScannerTests.swift @@ -7,10 +7,7 @@ final class IncrementalJSONLScannerTests: XCTestCase { let base = try makeDirectory("Persistence") defer { try? FileManager.default.removeItem(at: base) } let file = try makeFile(named: "usage.jsonl", contents: "7", in: base, mtime: Date()) - let persistence = JSONLScanCachePersistence( - namespace: "test", schemaVersion: 1, - directory: base.appendingPathComponent("cache"), writeDebounce: .milliseconds(1) - ) + let persistence = makePersistence(in: base) let firstCounter = ParseCounter() let first = IncrementalJSONLScanner(persistence: persistence) @@ -44,10 +41,7 @@ final class IncrementalJSONLScannerTests: XCTestCase { let now = Date() let firstFile = try makeFile(named: "a.jsonl", contents: "1", in: base, mtime: now) let secondFile = try makeFile(named: "b.jsonl", contents: "2", in: base, mtime: now) - let persistence = JSONLScanCachePersistence( - namespace: "test", schemaVersion: 1, - directory: base.appendingPathComponent("cache"), writeDebounce: .milliseconds(1) - ) + let persistence = makePersistence(in: base) let seed = IncrementalJSONLScanner(persistence: persistence) _ = await seed.items( @@ -96,17 +90,12 @@ final class IncrementalJSONLScannerTests: XCTestCase { let base = try makeDirectory("SchemaInvalidation") defer { try? FileManager.default.removeItem(at: base) } let file = try makeFile(named: "usage.jsonl", contents: "7", in: base, mtime: Date()) - let cacheDirectory = base.appendingPathComponent("cache") - let versionOne = JSONLScanCachePersistence( - namespace: "test", schemaVersion: 1, directory: cacheDirectory, writeDebounce: .milliseconds(1) - ) + let versionOne = makePersistence(in: base) let seed = IncrementalJSONLScanner(persistence: versionOne) _ = await seed.items(from: [file], since: .distantPast, cacheIdentity: "home", parse: ParseCounter().parse) await seed.waitForPendingWritesForTesting() - let versionTwo = JSONLScanCachePersistence( - namespace: "test", schemaVersion: 2, directory: cacheDirectory, writeDebounce: .milliseconds(1) - ) + let versionTwo = makePersistence(in: base, schemaVersion: 2) let counter = ParseCounter() let rebuilt = IncrementalJSONLScanner(persistence: versionTwo) let rebuiltItems = await rebuilt.items( @@ -125,10 +114,7 @@ final class IncrementalJSONLScannerTests: XCTestCase { named: "a.jsonl", contents: "1", in: base, mtime: now.addingTimeInterval(-10) ) let secondFile = try makeFile(named: "b.jsonl", contents: "2", in: base, mtime: now) - let persistence = JSONLScanCachePersistence( - namespace: "test", schemaVersion: 1, - directory: base.appendingPathComponent("cache"), writeDebounce: .milliseconds(1) - ) + let persistence = makePersistence(in: base) let scanner = IncrementalJSONLScanner(persistence: persistence) let parser = ParseCounter() @@ -159,10 +145,7 @@ final class IncrementalJSONLScannerTests: XCTestCase { let now = Date() let firstFile = try makeFile(named: "a.jsonl", contents: "1", in: base, mtime: now) let secondFile = try makeFile(named: "b.jsonl", contents: "2", in: base, mtime: now) - let persistence = JSONLScanCachePersistence( - namespace: "test", schemaVersion: 1, - directory: base.appendingPathComponent("cache"), writeDebounce: .milliseconds(1) - ) + let persistence = makePersistence(in: base) let scanner = IncrementalJSONLScanner(persistence: persistence) _ = await scanner.items( from: [firstFile, secondFile], since: .distantPast, cacheIdentity: "home", parse: ParseCounter().parse @@ -206,10 +189,7 @@ final class IncrementalJSONLScannerTests: XCTestCase { let now = Date() let firstFile = try makeFile(named: "a.jsonl", contents: "1", in: base, mtime: now) let secondFile = try makeFile(named: "b.jsonl", contents: "2", in: base, mtime: now) - let persistence = JSONLScanCachePersistence( - namespace: "test", schemaVersion: 1, - directory: base.appendingPathComponent("cache"), writeDebounce: .milliseconds(1) - ) + let persistence = makePersistence(in: base) let parser = ParseCounter() let scanner = IncrementalJSONLScanner(persistence: persistence) @@ -243,10 +223,7 @@ final class IncrementalJSONLScannerTests: XCTestCase { func testStaleIdentityDirectoryIsPruned() async throws { let base = try makeDirectory("IdentityPruning") defer { try? FileManager.default.removeItem(at: base) } - let persistence = JSONLScanCachePersistence( - namespace: "test", schemaVersion: 1, - directory: base.appendingPathComponent("cache"), writeDebounce: .milliseconds(1) - ) + let persistence = makePersistence(in: base) let file = try makeFile(named: "usage.jsonl", contents: "7", in: base, mtime: Date()) let scanner = IncrementalJSONLScanner(persistence: persistence) _ = await scanner.items(from: [file], since: .distantPast, cacheIdentity: "old-home", parse: ParseCounter().parse) @@ -286,13 +263,11 @@ final class IncrementalJSONLScannerTests: XCTestCase { } func testLimitsConcurrentParsesAndKeepsFileOrder() async throws { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent("OpenUsageScannerTests-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let directory = try makeDirectory("Concurrency") defer { try? FileManager.default.removeItem(at: directory) } let now = Date() - let files = try (0..<20).map { index in + let files = try (0..<6).map { index in let url = directory.appendingPathComponent(String(format: "%02d.jsonl", index)) let data = Data("\(index)".utf8) try data.write(to: url) @@ -308,14 +283,12 @@ final class IncrementalJSONLScannerTests: XCTestCase { return String(data: data, encoding: .utf8).flatMap(Int.init).map { [$0] } } - XCTAssertEqual(items, Array(0..<20)) + XCTAssertEqual(items, Array(0..<6)) XCTAssertLessThanOrEqual(probe.maximumActive, 3) } func testUnreadableFileWarnsOnceUntilItRecovers() async throws { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent("OpenUsageScannerWarnings-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let directory = try makeDirectory("Warnings") defer { try? FileManager.default.removeItem(at: directory) } let path = directory.appendingPathComponent("unreadable.jsonl") @@ -353,9 +326,7 @@ final class IncrementalJSONLScannerTests: XCTestCase { } func testScanningAnotherBatchDoesNotForgetAnUnreadableFile() async throws { - let directory = FileManager.default.temporaryDirectory - .appendingPathComponent("OpenUsageScannerWarningBatches-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let directory = try makeDirectory("WarningBatches") defer { try? FileManager.default.removeItem(at: directory) } let unreadableURL = directory.appendingPathComponent("a.jsonl") @@ -417,6 +388,15 @@ final class IncrementalJSONLScannerTests: XCTestCase { return directory } + private func makePersistence(in directory: URL, schemaVersion: Int = 1) -> JSONLScanCachePersistence { + JSONLScanCachePersistence( + namespace: "test", + schemaVersion: schemaVersion, + directory: directory.appendingPathComponent("cache"), + writeDebounce: .milliseconds(1) + ) + } + private func makeFile(named name: String, contents: String, in directory: URL, mtime: Date) throws -> JSONLScanning.DiscoveredFile { diff --git a/Tests/OpenUsageTests/JSONLScanCacheStoreTests.swift b/Tests/OpenUsageTests/JSONLScanCacheStoreTests.swift index 2e81bc38b..1346696c0 100644 --- a/Tests/OpenUsageTests/JSONLScanCacheStoreTests.swift +++ b/Tests/OpenUsageTests/JSONLScanCacheStoreTests.swift @@ -3,16 +3,20 @@ import XCTest @testable import OpenUsage final class JSONLScanCacheStoreTests: XCTestCase { - func testWriterMergesDisjointPathUpdatesFromSeparateSnapshots() async throws { - let base = FileManager.default.temporaryDirectory - .appendingPathComponent("OpenUsageCacheWriterTests-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: base) } + private var base: URL! + + override func setUpWithError() throws { + base = FileManager.default.temporaryDirectory + .appendingPathComponent("OpenUsageCacheTests-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) - let persistence = JSONLScanCachePersistence( - namespace: "test", - schemaVersion: 1, - directory: base.appendingPathComponent("cache") - ) + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: base) + } + + func testWriterMergesDisjointPathUpdatesFromSeparateSnapshots() async throws { + let persistence = makePersistence() let identity = "home" let first = try writeSource( "first", @@ -50,30 +54,18 @@ final class JSONLScanCacheStoreTests: XCTestCase { let snapshot = try XCTUnwrap(loaded) XCTAssertEqual(Set(snapshot.manifest.files.keys), Set([first.path, second.path])) - let firstRecordURL = JSONLScanCachePaths.recordURL( - persistence: persistence, - identity: identity, - fileName: JSONLScanCachePaths.recordFileName(path: first.path) - ) - let secondRecordURL = JSONLScanCachePaths.recordURL( - persistence: persistence, - identity: identity, - fileName: JSONLScanCachePaths.recordFileName(path: second.path) - ) - XCTAssertEqual(try Data(contentsOf: firstRecordURL), Data("first-record".utf8)) - XCTAssertEqual(try Data(contentsOf: secondRecordURL), Data("second-record".utf8)) + for (file, expectedRecord) in [(first, "first-record"), (second, "second-record")] { + let recordURL = JSONLScanCachePaths.recordURL( + persistence: persistence, + identity: identity, + fileName: JSONLScanCachePaths.recordFileName(path: file.path) + ) + XCTAssertEqual(try Data(contentsOf: recordURL), Data(expectedRecord.utf8), file.path) + } } func testWriterRejectsAStaleUpsertAfterTheSourceChanges() async throws { - let base = FileManager.default.temporaryDirectory - .appendingPathComponent("OpenUsageCacheStaleSourceTests-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: base) } - try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) - let persistence = JSONLScanCachePersistence( - namespace: "test", - schemaVersion: 1, - directory: base.appendingPathComponent("cache") - ) + let persistence = makePersistence() let sourceURL = base.appendingPathComponent("usage.jsonl") let stale = try writeSource("old", to: sourceURL, mtime: Date(timeIntervalSince1970: 2_000)) let staleBatch = makeBatch( @@ -91,15 +83,7 @@ final class JSONLScanCacheStoreTests: XCTestCase { } func testStaleRemovalDoesNotDeleteANewerPathUpdate() async throws { - let base = FileManager.default.temporaryDirectory - .appendingPathComponent("OpenUsageCacheRemovalMergeTests-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: base) } - try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) - let persistence = JSONLScanCachePersistence( - namespace: "test", - schemaVersion: 1, - directory: base.appendingPathComponent("cache") - ) + let persistence = makePersistence() let sourceURL = base.appendingPathComponent("usage.jsonl") let oldFile = try writeSource("old", to: sourceURL, mtime: Date(timeIntervalSince1970: 2_000)) let oldBatch = makeBatch( @@ -139,15 +123,7 @@ final class JSONLScanCacheStoreTests: XCTestCase { } func testLoadingAnOldIdentityTouchesItBeforeStalePruning() async throws { - let base = FileManager.default.temporaryDirectory - .appendingPathComponent("OpenUsageCacheLoadTouchTests-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: base) } - try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) - let persistence = JSONLScanCachePersistence( - namespace: "test", - schemaVersion: 1, - directory: base.appendingPathComponent("cache") - ) + let persistence = makePersistence() let file = try writeSource( "value", to: base.appendingPathComponent("usage.jsonl"), @@ -181,17 +157,8 @@ final class JSONLScanCacheStoreTests: XCTestCase { } func testFreshScannerCanPersistAfterAnotherScannerHasWrittenRepeatedly() async throws { - let base = FileManager.default.temporaryDirectory - .appendingPathComponent("OpenUsageCacheGenerationTests-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: base) } - try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) let sourceURL = base.appendingPathComponent("usage.jsonl") - let persistence = JSONLScanCachePersistence( - namespace: "test", - schemaVersion: 1, - directory: base.appendingPathComponent("cache"), - writeDebounce: .milliseconds(1) - ) + let persistence = makePersistence(writeDebounce: .milliseconds(1)) let firstScanner = IncrementalJSONLScanner(persistence: persistence) let baseDate = Date(timeIntervalSince1970: 10_000) @@ -239,21 +206,12 @@ final class JSONLScanCacheStoreTests: XCTestCase { } func testFlushPendingWritesBypassesTheDebounceForOneShotProcesses() async throws { - let base = FileManager.default.temporaryDirectory - .appendingPathComponent("OpenUsageCacheFlushTests-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: base) } - try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) let file = try writeSource( "7", to: base.appendingPathComponent("usage.jsonl"), mtime: Date() ) - let persistence = JSONLScanCachePersistence( - namespace: "test", - schemaVersion: 1, - directory: base.appendingPathComponent("cache"), - writeDebounce: .seconds(60) - ) + let persistence = makePersistence(writeDebounce: .seconds(60)) let scanner = IncrementalJSONLScanner(persistence: persistence) _ = await scanner.items( from: [file], @@ -277,10 +235,6 @@ final class JSONLScanCacheStoreTests: XCTestCase { } func testFlushRetriesDirtyWriteAfterDebouncedFailure() async throws { - let base = FileManager.default.temporaryDirectory - .appendingPathComponent("OpenUsageCacheFlushRetryTests-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: base) } - try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) let file = try writeSource( "7", to: base.appendingPathComponent("usage.jsonl"), @@ -288,12 +242,7 @@ final class JSONLScanCacheStoreTests: XCTestCase { ) let cacheDirectory = base.appendingPathComponent("cache") try Data("blocks-directory-creation".utf8).write(to: cacheDirectory) - let persistence = JSONLScanCachePersistence( - namespace: "test", - schemaVersion: 1, - directory: cacheDirectory, - writeDebounce: .milliseconds(1) - ) + let persistence = makePersistence(writeDebounce: .milliseconds(1)) let scanner = IncrementalJSONLScanner(persistence: persistence) _ = await scanner.items( from: [file], @@ -320,13 +269,9 @@ final class JSONLScanCacheStoreTests: XCTestCase { } func testParseLimitIsSharedAcrossDifferentIdentities() async throws { - let base = FileManager.default.temporaryDirectory - .appendingPathComponent("OpenUsageCachePermitTests-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: base) } - try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) let now = Date() - let firstFiles = try makeIntegerFiles(range: 0..<8, prefix: "a", in: base, mtime: now) - let secondFiles = try makeIntegerFiles(range: 8..<16, prefix: "b", in: base, mtime: now) + let firstFiles = try makeIntegerFiles(range: 0..<4, prefix: "a", in: base, mtime: now) + let secondFiles = try makeIntegerFiles(range: 4..<8, prefix: "b", in: base, mtime: now) let scanner = IncrementalJSONLScanner(maxConcurrentParses: 3) let probe = ConcurrencyProbe() let parse: @Sendable (Data) -> [Int]? = { data in @@ -350,22 +295,13 @@ final class JSONLScanCacheStoreTests: XCTestCase { ) let results = await (first, second) - XCTAssertEqual(results.0, Array(0..<8)) - XCTAssertEqual(results.1, Array(8..<16)) + XCTAssertEqual(results.0, Array(0..<4)) + XCTAssertEqual(results.1, Array(4..<8)) XCTAssertLessThanOrEqual(probe.maximumActive, 3) } func testStaleLocalEvictionCannotRemoveANewerExternalRecord() async throws { - let base = FileManager.default.temporaryDirectory - .appendingPathComponent("OpenUsageCacheExternalUpdateTests-\(UUID().uuidString)", isDirectory: true) - defer { try? FileManager.default.removeItem(at: base) } - try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true) - let persistence = JSONLScanCachePersistence( - namespace: "test", - schemaVersion: 1, - directory: base.appendingPathComponent("cache"), - writeDebounce: .milliseconds(1) - ) + let persistence = makePersistence(writeDebounce: .milliseconds(1)) let identity = "home" let baseDate = Date(timeIntervalSince1970: 10_000) let primaryURL = base.appendingPathComponent("primary.jsonl") @@ -425,6 +361,15 @@ final class JSONLScanCacheStoreTests: XCTestCase { XCTAssertEqual(reloadCounter.count, 0) } + private func makePersistence(writeDebounce: Duration = .seconds(2)) -> JSONLScanCachePersistence { + JSONLScanCachePersistence( + namespace: "test", + schemaVersion: 1, + directory: base.appendingPathComponent("cache"), + writeDebounce: writeDebounce + ) + } + private func makeBatch( persistence: JSONLScanCachePersistence, identity: String, diff --git a/Tests/OpenUsageTests/LayoutStoreTests.swift b/Tests/OpenUsageTests/LayoutStoreTests.swift index 5e670ec23..7dd004886 100644 --- a/Tests/OpenUsageTests/LayoutStoreTests.swift +++ b/Tests/OpenUsageTests/LayoutStoreTests.swift @@ -58,6 +58,7 @@ final class LayoutStoreTests: XCTestCase { XCTAssertTrue(store.undo()) XCTAssertFalse(store.isMetricEnabled("cursor.credits"), "undo turns an enabled metric back off") + XCTAssertFalse(store.canUndo, "undo must not record itself") } func testUndoReversesMetricReorder() { @@ -165,16 +166,6 @@ final class LayoutStoreTests: XCTestCase { XCTAssertFalse(store.undo()) } - func testUndoIsNotItselfRecorded() { - // Applying an undo must not push a new step — otherwise ⌘Z would ping-pong forever. - let store = makeStore("UndoNotRecorded") - store.setMetricEnabled("cursor.credits", true) - XCTAssertTrue(store.canUndo) - - XCTAssertTrue(store.undo()) - XCTAssertFalse(store.canUndo, "undo leaves nothing new to undo") - } - func testUndoStackIsCappedAtMaxDepth() { let store = makeStore("UndoMaxDepth") // Drive more distinct, recordable changes than the cap by toggling a pin on and off repeatedly. @@ -211,43 +202,21 @@ final class LayoutStoreTests: XCTestCase { XCTAssertEqual(store.placed.map(\.descriptorID), before) } - func testResetToDefaultClearsUndoHistory() { - let store = makeStore("UndoResetAllClears") - store.setMetricEnabled("claude.weekly", true) - store.setMetricEnabled("claude.weekly", false) - XCTAssertTrue(store.canUndo) - - store.resetToDefault() + func testGlobalAndProviderResetsClearUndoHistory() { + for providerOnly in [false, true] { + let store = makeStore(providerOnly ? "UndoResetProvider" : "UndoResetAll") + store.setMetricEnabled("cursor.credits", true) + XCTAssertTrue(store.canUndo) - XCTAssertFalse(store.canUndo) - XCTAssertFalse(store.undo()) - } + if providerOnly { + store.resetProvider("claude") + } else { + store.resetToDefault() + } - func testResetProviderClearsUndoHistory() { - let store = makeStore("UndoResetProviderClears") - store.setMetricEnabled("cursor.credits", true) - store.setMetricEnabled("cursor.requests", true) - XCTAssertTrue(store.canUndo) - - store.resetProvider("claude") - - // Snapshots are whole-layout, so a reset (its own deliberate action) drops the entire stack. - XCTAssertFalse(store.canUndo) - XCTAssertFalse(store.undo()) - } - - func testDirectRemoveDoesNotRecordUndo() { - // The low-level `remove(_:)` (used by drag teardown and tests) is not a user-facing seam, so it - // doesn't feed the undo stack — only the wrapped mutations (setMetricEnabled, reorder, pin) do. - let store = makeStore("UndoDirectRemove") - store.placed = [PlacedWidget(descriptorID: "claude.weekly")] - guard let widget = store.placed.first(where: { $0.descriptorID == "claude.weekly" }) else { - return XCTFail("metric was not placed") + XCTAssertFalse(store.canUndo) + XCTAssertFalse(store.undo()) } - - store.remove(widget.id) - - XCTAssertFalse(store.canUndo) } func testSavedEmptyLayoutDoesNotRestoreDefaults() { @@ -660,36 +629,24 @@ final class LayoutStoreTests: XCTestCase { } func testFreshCustomizeOrderFollowsProviderDeclarations() { - let registry = WidgetRegistry.from([ + let providers: [ProviderRuntime] = [ ClaudeProvider(), CodexProvider(), DevinProvider(), GrokProvider(), CursorProvider() - ]) - let store = LayoutStore(registry: registry, defaults: makeDefaults("FreshCustomizeOrder"), storageKey: "layout") + ] + let store = LayoutStore( + registry: WidgetRegistry.from(providers), defaults: makeDefaults("FreshCustomizeOrder"), storageKey: "layout" + ) - XCTAssertEqual(store.orderedSupportedMetrics(for: "claude").map(\.id), [ - "claude.session", "claude.weekly", "claude.fable", "claude.sonnet", "claude.extra", - "claude.trend", "claude.today", "claude.yesterday", "claude.last30" - ]) - XCTAssertEqual(store.orderedSupportedMetrics(for: "codex").map(\.id), [ - "codex.session", "codex.weekly", "codex.spark", "codex.sparkWeekly", - "codex.credits", "codex.rateLimitResets", - "codex.trend", "codex.today", "codex.yesterday", "codex.last30" - ]) - XCTAssertEqual(store.orderedSupportedMetrics(for: "devin").map(\.id), [ - "devin.daily", "devin.weekly", "devin.extra" - ]) - XCTAssertEqual(store.orderedSupportedMetrics(for: "grok").map(\.id), [ - "grok.weekly", "grok.payAsYouGo", - "grok.trend", "grok.today", "grok.yesterday", "grok.last30" - ]) - // Cursor's spend tiles + usage trend are enabled, so they trail the live meters in declaration order. - XCTAssertEqual(store.orderedSupportedMetrics(for: "cursor").map(\.id), [ - "cursor.usage", "cursor.auto", "cursor.api", "cursor.grokBot", "cursor.onDemand", "cursor.requests", - "cursor.credits", "cursor.trend", "cursor.today", "cursor.yesterday", "cursor.last30" - ]) + for provider in providers { + XCTAssertEqual( + store.orderedSupportedMetrics(for: provider.provider.id).map(\.id), + provider.widgetDescriptors.map(\.id), + provider.provider.id + ) + } } func testFreshDefaultLayoutMatchesRecommendedMetricSections() { @@ -1101,25 +1058,15 @@ final class LayoutStoreTests: XCTestCase { XCTAssertTrue(group?.expandedWidgets.isEmpty ?? false) } - func testProviderExpandedStatePersistsAcrossReload() { + func testProviderExpandedAndCollapsedStatesPersistAcrossReload() { let defaults = makeDefaults("ProviderExpanded") let store = LayoutStore(registry: .mock, defaults: defaults, storageKey: "layout") XCTAssertTrue(store.setProviderExpanded(true, for: "codex")) - XCTAssertTrue(store.isProviderExpanded("codex")) - - let reloaded = LayoutStore(registry: .mock, defaults: defaults, storageKey: "layout") - XCTAssertTrue(reloaded.isProviderExpanded("codex")) - } + XCTAssertTrue(LayoutStore(registry: .mock, defaults: defaults, storageKey: "layout").isProviderExpanded("codex")) - func testProviderExpandedStateCanCollapseAndPersists() { - let defaults = makeDefaults("ProviderCollapsed") - let store = LayoutStore(registry: .mock, defaults: defaults, storageKey: "layout") - XCTAssertTrue(store.setProviderExpanded(true, for: "codex")) XCTAssertTrue(store.setProviderExpanded(false, for: "codex")) - - let reloaded = LayoutStore(registry: .mock, defaults: defaults, storageKey: "layout") - XCTAssertFalse(reloaded.isProviderExpanded("codex")) + XCTAssertFalse(LayoutStore(registry: .mock, defaults: defaults, storageKey: "layout").isProviderExpanded("codex")) } func testInvalidPersistedExpandedProviderIDsAreDropped() { @@ -1215,7 +1162,9 @@ final class LayoutStoreTests: XCTestCase { let store = makeStore("RowCounts") for row in store.customizeProviderRows { XCTAssertEqual(row.metricCount, MockData.descriptors(for: row.id).count) + XCTAssertEqual(store.metricCount(for: row.id), row.metricCount) } + XCTAssertEqual(store.metricCount(for: "missing"), 0) } func testCustomizeDetailReturnsMetricsEvenWhenDisabled() { @@ -1250,14 +1199,6 @@ final class LayoutStoreTests: XCTestCase { XCTAssertNil(store.customizeDetail(for: "nope")) } - func testMetricCountMatchesRegistryDescriptors() { - let store = makeStore("MetricCount") - for id in MockData.providers.map(\.id) { - XCTAssertEqual(store.metricCount(for: id), MockData.descriptors(for: id).count) - } - XCTAssertEqual(store.metricCount(for: "missing"), 0) - } - func testCustomizeProviderIDClearsWhenLeavingCustomize() { let store = makeStore("RouteClears") store.screen = .customize diff --git a/Tests/OpenUsageTests/LegacyLaunchAgentCleanupTests.swift b/Tests/OpenUsageTests/LegacyLaunchAgentCleanupTests.swift index 2d96ac8d3..fbc84c623 100644 --- a/Tests/OpenUsageTests/LegacyLaunchAgentCleanupTests.swift +++ b/Tests/OpenUsageTests/LegacyLaunchAgentCleanupTests.swift @@ -4,71 +4,32 @@ import XCTest final class LegacyLaunchAgentCleanupTests: XCTestCase { // MARK: - Removal decision (pure) - /// The real-world case from #874: the Tauri-era agent points at the lowercase binary name, which - /// case-insensitive APFS resolves into this edition's bundle. - func testTauriLowercaseProgramInsideBundleIsRemoved() { - XCTAssertTrue(LegacyLaunchAgentCleanup.shouldRemove( - programPath: "/Applications/OpenUsage.app/Contents/MacOS/openusage", - bundlePath: "/Applications/OpenUsage.app" - )) - } - - func testExactCaseProgramInsideBundleIsRemoved() { - XCTAssertTrue(LegacyLaunchAgentCleanup.shouldRemove( - programPath: "/Applications/OpenUsage.app/Contents/MacOS/OpenUsage", - bundlePath: "/Applications/OpenUsage.app" - )) - } - - /// A hand-rolled agent pointing anywhere else must be left alone. - func testProgramOutsideBundleIsKept() { - XCTAssertFalse(LegacyLaunchAgentCleanup.shouldRemove( - programPath: "/usr/local/bin/openusage", - bundlePath: "/Applications/OpenUsage.app" - )) - } - - func testMissingProgramIsKept() { - XCTAssertFalse(LegacyLaunchAgentCleanup.shouldRemove( - programPath: nil, - bundlePath: "/Applications/OpenUsage.app" - )) - } - - /// Unbundled runs (`swift run`) report a build directory as the bundle path; that must never - /// match, so a dev run can't delete a user's real agent by accident. - func testNonAppBundlePathNeverMatches() { - XCTAssertFalse(LegacyLaunchAgentCleanup.shouldRemove( - programPath: "/Users/dev/openusage/.build/debug/OpenUsage", - bundlePath: "/Users/dev/openusage/.build/debug" - )) - } - - /// Prefix matching must be component-wise: a sibling like `OpenUsage.app2` shares the string - /// prefix but is a different bundle. - func testSiblingDirectorySharingPrefixIsKept() { - XCTAssertFalse(LegacyLaunchAgentCleanup.shouldRemove( - programPath: "/Applications/OpenUsage.app2/Contents/MacOS/openusage", - bundlePath: "/Applications/OpenUsage.app" - )) - } - - /// The bundle path itself (no inner component) is not a program inside the bundle. - func testProgramEqualToBundlePathIsKept() { - XCTAssertFalse(LegacyLaunchAgentCleanup.shouldRemove( - programPath: "/Applications/OpenUsage.app", - bundlePath: "/Applications/OpenUsage.app" - )) + func testRemovalRequiresAProgramInsideTheCurrentAppBundle() { + let bundle = "/Applications/OpenUsage.app" + let scenarios: [(name: String, program: String?, bundle: String, remove: Bool)] = [ + ("legacy lowercase executable", "\(bundle)/Contents/MacOS/openusage", bundle, true), + ("current executable", "\(bundle)/Contents/MacOS/OpenUsage", bundle, true), + ("foreign executable", "/usr/local/bin/openusage", bundle, false), + ("missing executable", nil, bundle, false), + ("unbundled development build", "/Users/dev/.build/OpenUsage", "/Users/dev/.build", false), + ("sibling with matching prefix", "\(bundle)2/Contents/MacOS/openusage", bundle, false), + ("bundle itself", bundle, bundle, false) + ] + + for scenario in scenarios { + XCTAssertEqual( + LegacyLaunchAgentCleanup.shouldRemove(programPath: scenario.program, bundlePath: scenario.bundle), + scenario.remove, + scenario.name + ) + } } // MARK: - Plist parsing func testParseReadsFirstProgramArgument() throws { - // Shape-faithful to what tauri-plugin-autostart wrote (see #874). let agent = try LegacyLaunchAgentCleanup.parse(plistData: plist([ - "Label": "OpenUsage", - "ProgramArguments": ["/Applications/OpenUsage.app/Contents/MacOS/openusage"], - "RunAtLoad": true + "ProgramArguments": ["/Applications/OpenUsage.app/Contents/MacOS/openusage"] ])) XCTAssertEqual(agent.programPath, "/Applications/OpenUsage.app/Contents/MacOS/openusage") } @@ -94,9 +55,7 @@ final class LegacyLaunchAgentCleanupTests: XCTestCase { func testLeftoverTauriAgentFileIsDeleted() throws { let agentURL = try writeAgent([ - "Label": "OpenUsage", - "ProgramArguments": ["/Applications/OpenUsage.app/Contents/MacOS/openusage"], - "RunAtLoad": true + "ProgramArguments": ["/Applications/OpenUsage.app/Contents/MacOS/openusage"] ]) defer { try? FileManager.default.removeItem(at: agentURL.deletingLastPathComponent()) } @@ -109,7 +68,6 @@ final class LegacyLaunchAgentCleanupTests: XCTestCase { func testForeignAgentFileIsKept() throws { let agentURL = try writeAgent([ - "Label": "OpenUsage", "ProgramArguments": ["/usr/local/bin/something-else"] ]) defer { try? FileManager.default.removeItem(at: agentURL.deletingLastPathComponent()) } diff --git a/Tests/OpenUsageTests/LocalLimitsAPITests.swift b/Tests/OpenUsageTests/LocalLimitsAPITests.swift index ed7a4851b..f7576aeb5 100644 --- a/Tests/OpenUsageTests/LocalLimitsAPITests.swift +++ b/Tests/OpenUsageTests/LocalLimitsAPITests.swift @@ -37,13 +37,7 @@ final class LocalLimitsAPITests: XCTestCase { ], refreshedAt: fetchedAt ) - let state = LocalUsageAPI.State( - enabledOrderedIDs: ["codex"], - knownIDs: ["codex"], - snapshots: ["codex": snapshot], - limitDescriptors: ["codex": [session, credits]], - generatedAt: generatedAt - ) + let state = makeState(snapshot: snapshot, descriptors: [session, credits]) let response = LocalUsageAPI.respond(method: "GET", path: "/v1/limits", state: state) let root = try json(response.body) @@ -92,13 +86,7 @@ final class LocalLimitsAPITests: XCTestCase { lines: [.progress(label: "Session", used: 73, limit: 100, format: .percent)], refreshedAt: fetchedAt ) - let state = LocalUsageAPI.State( - enabledOrderedIDs: ["codex"], - knownIDs: ["codex"], - snapshots: ["codex": snapshot], - limitDescriptors: ["codex": [session]], - generatedAt: generatedAt - ) + let state = makeState(snapshot: snapshot, descriptors: [session]) let usage = try XCTUnwrap( JSONSerialization.jsonObject( @@ -150,19 +138,7 @@ final class LocalLimitsAPITests: XCTestCase { lines: [.values(label: "Extra usage spent", values: [MetricValue(number: 12.5, kind: .dollars)])], refreshedAt: fetchedAt ) - let state = LocalUsageAPI.State( - enabledOrderedIDs: ["claude"], - knownIDs: ["claude"], - snapshots: ["claude": snapshot], - limitDescriptors: ["claude": [extra]], - generatedAt: generatedAt - ) - - let root = try json(LocalUsageAPI.respond(method: "GET", path: "/v1/limits", state: state).body) - let providers = try XCTUnwrap(root["providers"] as? [String: Any]) - let claude = try XCTUnwrap(providers["claude"] as? [String: Any]) - let resources = try XCTUnwrap(claude["resources"] as? [String: Any]) - let resource = try XCTUnwrap(resources["extraUsage"] as? [String: Any]) + let resource = try limitResource("extraUsage", snapshot: snapshot, descriptors: [extra]) XCTAssertEqual(resource["used"] as? Double, 12.5) XCTAssertNil(resource["limit"]) @@ -188,27 +164,12 @@ final class LocalLimitsAPITests: XCTestCase { refreshedAt: fetchedAt ) - func resource(in snapshot: ProviderSnapshot) throws -> [String: Any] { - let state = LocalUsageAPI.State( - enabledOrderedIDs: ["copilot"], - knownIDs: ["copilot"], - snapshots: ["copilot": snapshot], - limitDescriptors: ["copilot": [credits]], - generatedAt: generatedAt - ) - let root = try json(LocalUsageAPI.respond(method: "GET", path: "/v1/limits", state: state).body) - let providers = try XCTUnwrap(root["providers"] as? [String: Any]) - let copilot = try XCTUnwrap(providers["copilot"] as? [String: Any]) - let resources = try XCTUnwrap(copilot["resources"] as? [String: Any]) - return try XCTUnwrap(resources["premiumCredits"] as? [String: Any]) - } - - let count = try resource(in: personal) + let count = try limitResource("premiumCredits", snapshot: personal, descriptors: [credits]) XCTAssertEqual(count["used"] as? Double, 2111) XCTAssertEqual(count["unit"] as? String, "credits") XCTAssertNil(count["limit"]) - let percent = try resource(in: paid) + let percent = try limitResource("premiumCredits", snapshot: paid, descriptors: [credits]) XCTAssertEqual(percent["used"] as? Double, 59) XCTAssertEqual(percent["unit"] as? String, "percent") XCTAssertEqual(percent["limit"] as? Double, 100) @@ -228,19 +189,7 @@ final class LocalLimitsAPITests: XCTestCase { )], refreshedAt: fetchedAt ) - let state = LocalUsageAPI.State( - enabledOrderedIDs: ["cursor"], - knownIDs: ["cursor"], - snapshots: ["cursor": snapshot], - limitDescriptors: ["cursor": [total]], - generatedAt: generatedAt - ) - - let root = try json(LocalUsageAPI.respond(method: "GET", path: "/v1/limits", state: state).body) - let providers = try XCTUnwrap(root["providers"] as? [String: Any]) - let cursor = try XCTUnwrap(providers["cursor"] as? [String: Any]) - let resources = try XCTUnwrap(cursor["resources"] as? [String: Any]) - let resource = try XCTUnwrap(resources["totalUsage"] as? [String: Any]) + let resource = try limitResource("totalUsage", snapshot: snapshot, descriptors: [total]) XCTAssertEqual(resource["unit"] as? String, "requests") XCTAssertEqual(resource["used"] as? Double, 37) @@ -269,4 +218,25 @@ final class LocalLimitsAPITests: XCTestCase { XCTAssertEqual(actual, expected) } + + private func makeState(snapshot: ProviderSnapshot, descriptors: [WidgetDescriptor]) -> LocalUsageAPI.State { + LocalUsageAPI.State( + enabledOrderedIDs: [snapshot.providerID], + knownIDs: [snapshot.providerID], + snapshots: [snapshot.providerID: snapshot], + limitDescriptors: [snapshot.providerID: descriptors], + generatedAt: generatedAt + ) + } + + private func limitResource( + _ key: String, + snapshot: ProviderSnapshot, + descriptors: [WidgetDescriptor] + ) throws -> [String: Any] { + let state = makeState(snapshot: snapshot, descriptors: descriptors) + let root = try json(LocalUsageAPI.respond(method: "GET", path: "/v1/limits", state: state).body) + let provider = try XCTUnwrap((root["providers"] as? [String: Any])?[snapshot.providerID] as? [String: Any]) + return try XCTUnwrap((provider["resources"] as? [String: Any])?[key] as? [String: Any]) + } } diff --git a/Tests/OpenUsageTests/MenuBarPinTests.swift b/Tests/OpenUsageTests/MenuBarPinTests.swift index cc1e7526f..1e8f7e2b6 100644 --- a/Tests/OpenUsageTests/MenuBarPinTests.swift +++ b/Tests/OpenUsageTests/MenuBarPinTests.swift @@ -28,7 +28,7 @@ final class MenuBarPinTests: XCTestCase { XCTAssertFalse(reloadedAgain.isPinned("a.m1")) } - func testPerProviderCapBlocksThirdPin() { + func testPerProviderCapBlocksThirdPinUntilSlotIsFreed() { let store = makeStore("perProvider") store.setPinned(true, for: "a.m1") store.setPinned(true, for: "a.m2") @@ -40,16 +40,19 @@ final class MenuBarPinTests: XCTestCase { // An already-pinned id stays pinnable so its toggle can still unpin it. XCTAssertTrue(store.canPin("a.m1")) + + store.setPinned(false, for: "a.m1") + XCTAssertTrue(store.canPin("a.m3")) } func testEachProviderCanPinUpToTwo() { let store = makeStore("manyProviders") - for provider in ["a", "b", "c", "d"] { + for provider in ["a", "b"] { store.setPinned(true, for: "\(provider).m1") store.setPinned(true, for: "\(provider).m2") XCTAssertFalse(store.canPin("\(provider).m3")) } - XCTAssertEqual(store.pinnedMetricIDs.count, 8) + XCTAssertEqual(store.pinnedMetricIDs.count, 4) } func testPinDenialReasonsAndFooterNotice() { @@ -61,8 +64,6 @@ final class MenuBarPinTests: XCTestCase { XCTAssertEqual(store.pinDenialReason("a.m3"), "Up to 2 stars per provider") XCTAssertNil(store.pinDenialReason("b.m1")) - XCTAssertNil(store.pinDenialReason("b.m1")) - // A denied click surfaces the reason as the transient footer notice and bumps the shake // trigger every time, so repeat clicks re-shake even while the text is unchanged. XCTAssertNil(store.pinLimitNotice) @@ -74,16 +75,6 @@ final class MenuBarPinTests: XCTestCase { XCTAssertEqual(store.pinNoticeShakeTrigger, 2) } - func testUnpinFreesAProviderSlot() { - let store = makeStore("freeSlot") - store.setPinned(true, for: "a.m1") - store.setPinned(true, for: "a.m2") - XCTAssertFalse(store.canPin("a.m3")) - - store.setPinned(false, for: "a.m1") - XCTAssertTrue(store.canPin("a.m3")) - } - func testPinnedGroupsFollowCustomizeOrder() { let store = makeStore("order") // Pin out of order; expect provider order (a before b) and metric order (m1 before m2). @@ -140,9 +131,9 @@ final class MenuBarPinTests: XCTestCase { LayoutStore(registry: makeRegistry(), defaults: makeDefaults(name), storageKey: "layout") } - /// Four providers (a, b, c, d), each with three percent metrics m1/m2/m3, in registry order. + /// Two providers, each with three percent metrics, are sufficient to exercise provider-local caps. private func makeRegistry() -> WidgetRegistry { - let providers = ["a", "b", "c", "d"].map { id in + let providers = ["a", "b"].map { id in Provider(id: id, displayName: id.uppercased(), icon: .providerMark("cursor")) } let descriptors = providers.flatMap { provider in diff --git a/Tests/OpenUsageTests/MenuBarPrivacyStoreTests.swift b/Tests/OpenUsageTests/MenuBarPrivacyStoreTests.swift index 44feffec7..26fa1836e 100644 --- a/Tests/OpenUsageTests/MenuBarPrivacyStoreTests.swift +++ b/Tests/OpenUsageTests/MenuBarPrivacyStoreTests.swift @@ -35,16 +35,13 @@ final class MenuBarPrivacyStoreTests: XCTestCase { XCTAssertFalse(store.concealUsage) } - func testCaptureAloneDoesNotConceal() { - let store = makeStore("captureOnly", captured: { true }) - store.refreshCaptureState() - XCTAssertFalse(store.concealUsage, "A capture with the setting off must not conceal") - } - - func testSettingAloneDoesNotConceal() { - let store = makeStore("settingOnly", captured: { false }) - store.hideUsageWhileScreenSharing = true - XCTAssertFalse(store.concealUsage, "The setting without an active capture must not conceal") + func testCaptureAndSettingAloneNeverConceal() { + for (captured, enabled) in [(true, false), (false, true)] { + let store = makeStore("capture\(captured)-enabled\(enabled)", captured: { captured }) + store.hideUsageWhileScreenSharing = enabled + store.refreshCaptureState() + XCTAssertFalse(store.concealUsage) + } } func testEnablingDuringCaptureConcealsImmediately() { @@ -72,7 +69,7 @@ final class MenuBarPrivacyStoreTests: XCTestCase { XCTAssertFalse(store.concealUsage) } - func testDisablingClearsCaptureStateImmediately() { + func testDisablingClearsCaptureStateAndRejectsStaleNotifications() { let store = makeStore("disableClears", captured: { true }) store.hideUsageWhileScreenSharing = true XCTAssertTrue(store.concealUsage) @@ -80,6 +77,9 @@ final class MenuBarPrivacyStoreTests: XCTestCase { store.hideUsageWhileScreenSharing = false XCTAssertFalse(store.screenIsCaptured, "Opting out must drop the wordmark without waiting for a poll") XCTAssertFalse(store.concealUsage) + + store.refreshCaptureState() + XCTAssertFalse(store.concealUsage, "A stale capture notification cannot reconceal after opt-out") } func testSettingPersistsAcrossStores() { @@ -93,13 +93,4 @@ final class MenuBarPrivacyStoreTests: XCTestCase { XCTAssertTrue(relaunched.concealUsage) } - func testStaleNotificationAfterDisableCannotReconceal() { - let store = makeStore("staleEvent", captured: { true }) - store.hideUsageWhileScreenSharing = true - store.hideUsageWhileScreenSharing = false - // A window-server notification landing after opt-out re-runs the check; the setting gate - // must keep it from re-concealing. - store.refreshCaptureState() - XCTAssertFalse(store.concealUsage) - } } diff --git a/Tests/OpenUsageTests/MeterSeverityTests.swift b/Tests/OpenUsageTests/MeterSeverityTests.swift index ca22a9ee6..e26c9e2d4 100644 --- a/Tests/OpenUsageTests/MeterSeverityTests.swift +++ b/Tests/OpenUsageTests/MeterSeverityTests.swift @@ -30,22 +30,16 @@ final class MeterSeverityTests: XCTestCase { // MARK: Pace-driven (a live reset window) - func testBurningTooFastIsCriticalLongBeforeTheBarLooksFull() { - // 66% used but only a third of the week gone → projected ~182% → red, despite the - // absolute bands calling 66% "normal". This was the original complaint: a bar guaranteed - // to run out days early stayed calm blue. - XCTAssertEqual(severity(pacedData(used: 66, elapsed: 0.363)), .critical) - } - - func testCoastingToTheResetStaysNormalEvenWhenNearlyDrained() { - // 85% used with 96% of the window gone → projected ~89%, ≥10% to spare → blue, even - // though the absolute bands would call 85% "warning". - XCTAssertEqual(severity(pacedData(used: 85, elapsed: 0.96)), .normal) - } + func testPaceVerdictOverridesAbsoluteUsageBands() { + let cases: [(used: Double, elapsed: Double, severity: WidgetData.MeterSeverity)] = [ + (66, 0.363, .critical), + (85, 0.96, .normal), + (88, 0.9, .warning) + ] - func testProjectedIntoTheLastTenPercentIsWarning() { - // 88% used with 90% of the window gone → projected ~97.8% → amber. - XCTAssertEqual(severity(pacedData(used: 88, elapsed: 0.9)), .warning) + for testCase in cases { + XCTAssertEqual(severity(pacedData(used: testCase.used, elapsed: testCase.elapsed)), testCase.severity) + } } func testLimitReachedIsSpentRegardlessOfElapsed() { @@ -70,37 +64,24 @@ final class MeterSeverityTests: XCTestCase { func testEarlyInWindowUsesPaceVerdictNotAbsoluteBands() { // Early in the window still projects pace — heavy usage at 2% elapsed is already behind. - XCTAssertEqual(severity(pacedData(used: 50, elapsed: 0.02)), .critical) XCTAssertEqual(severity(pacedData(used: 85, elapsed: 0.02)), .critical) } // MARK: Absolute fallback (no reset window to project against) - func testComfortableUsageIsNormal() { - XCTAssertEqual(severity(percentData(used: 0)), .normal) - XCTAssertEqual(severity(percentData(used: 50)), .normal) - XCTAssertEqual(severity(percentData(used: 79)), .normal) - } - - func testWarningStartsAtEightyPercentUsed() { - XCTAssertEqual(severity(percentData(used: 80)), .warning) - XCTAssertEqual(severity(percentData(used: 89)), .warning) - } + func testAbsoluteSeverityThresholdsUseRoundedPercentages() { + let cases: [(used: Double, severity: WidgetData.MeterSeverity)] = [ + (0, .normal), (79, .normal), (79.6, .warning), (80, .warning), + (89.4, .warning), (89.6, .critical), (90, .critical) + ] - func testCriticalStartsAtTenPercentLeft() { - XCTAssertEqual(severity(percentData(used: 90)), .critical) - // Exactly at/over the limit is spent (still a red bar). + for testCase in cases { + XCTAssertEqual(severity(percentData(used: testCase.used)), testCase.severity, "used: \(testCase.used)") + } XCTAssertEqual(percentData(used: 100).meterState(now: now), .spent) XCTAssertEqual(percentData(used: 130).meterState(now: now), .spent) } - func testThresholdsUseTheHeadlinesWholePercentRounding() { - // 79.6% reads "80% used" in the headline, so it must already be yellow; same at 89.6% → red. - XCTAssertEqual(severity(percentData(used: 79.6)), .warning) - XCTAssertEqual(severity(percentData(used: 89.4)), .warning) - XCTAssertEqual(severity(percentData(used: 89.6)), .critical) - } - func testSeverityIgnoresTheUsedLeftDisplayMode() { // In Left mode the bar *fill* shows the remaining share, but the color still keys off usage. XCTAssertEqual(severity(percentData(used: 95, displayMode: .remaining)), .critical) diff --git a/Tests/OpenUsageTests/MetricFormatterTests.swift b/Tests/OpenUsageTests/MetricFormatterTests.swift index c767360ff..2096031fe 100644 --- a/Tests/OpenUsageTests/MetricFormatterTests.swift +++ b/Tests/OpenUsageTests/MetricFormatterTests.swift @@ -58,28 +58,20 @@ final class MetricFormatterTests: XCTestCase { } func testTotalSpendRingCenterSplitsValueAndUnit() { - let spend = MetricFormatter.totalSpendRingCenter(533, metric: .cost) - XCTAssertEqual(spend.primary, "$533") - XCTAssertEqual(spend.unit, "dollars") + let cases: [(value: Double, metric: TotalSpendMetric, primary: String, unit: String)] = [ + (533, .cost, "$533", "dollars"), + (2059.07, .cost, "$2.1K", "dollars"), + (12_400_000, .tokens, "12.4", "million"), + (1_500_000_000, .tokens, "1.5", "billion"), + (820.6, .tokens, "820.6", "tokens"), + (1.37, .costPerMtok, "$1.37", "MTok") + ] - let spendAbbrev = MetricFormatter.totalSpendRingCenter(2059.07, metric: .cost) - XCTAssertEqual(spendAbbrev.primary, "$2.1K") - XCTAssertEqual(spendAbbrev.unit, "dollars") - - let tokens = MetricFormatter.totalSpendRingCenter(12_400_000, metric: .tokens) - XCTAssertEqual(tokens.primary, "12.4") - XCTAssertEqual(tokens.unit, "million") - - let billions = MetricFormatter.totalSpendRingCenter(1_500_000_000, metric: .tokens) - XCTAssertEqual(billions.primary, "1.5") - XCTAssertEqual(billions.unit, "billion") - - let smallTokens = MetricFormatter.totalSpendRingCenter(820.6, metric: .tokens) - XCTAssertEqual(smallTokens.primary, "820.6") - XCTAssertEqual(smallTokens.unit, "tokens") - - let rate = MetricFormatter.totalSpendRingCenter(1.37, metric: .costPerMtok) - XCTAssertEqual(rate.primary, "$1.37") - XCTAssertEqual(rate.unit, "MTok") + for testCase in cases { + XCTAssertEqual( + MetricFormatter.totalSpendRingCenter(testCase.value, metric: testCase.metric), + .init(primary: testCase.primary, unit: testCase.unit) + ) + } } } diff --git a/Tests/OpenUsageTests/ModelPricingStoreTests.swift b/Tests/OpenUsageTests/ModelPricingStoreTests.swift index eb20b65f0..f8ea8626c 100644 --- a/Tests/OpenUsageTests/ModelPricingStoreTests.swift +++ b/Tests/OpenUsageTests/ModelPricingStoreTests.swift @@ -154,46 +154,20 @@ final class ModelPricingStoreTests: XCTestCase { XCTAssertEqual(pricing.resolve(model: "fetched-model")?.inputPerMillion, 5) } - /// An app update ships a newer bundled supplement while the cache from the previous version is - /// still on disk. The shipped rates must win until the feed catches up — otherwise a fix that - /// merged and shipped stays invisible, permanently for anyone who can't reach the feed. - func testNewerBundledSupplementBeatsOlderCache() async throws { - try writeSupplementCache(updatedAt: "2026-01-01", autoInput: 9) - - let store = makeStoreWithBundledSupplement(updatedAt: "2026-06-01", autoInput: 4) - let pricing = await store.current() - XCTAssertEqual(pricing.resolve(model: "auto")?.inputPerMillion, 4) - } - - /// Multiple supplement changes can land on one day. A precise bundled timestamp must beat a - /// legacy date-only cache from that day, or the cache hides newly shipped aliases and rates. - func testTimestampedBundledSupplementBeatsSameDayDateOnlyCache() async throws { - try writeSupplementCache(updatedAt: "2026-08-11", autoInput: 9) - - let store = makeStoreWithBundledSupplement(updatedAt: "2026-08-11T07:01:30Z", autoInput: 4) - let pricing = await store.current() - XCTAssertEqual(pricing.resolve(model: "auto")?.inputPerMillion, 4) - } - - /// The usual case: the feed runs ahead of the shipped file, so the cache keeps winning. - func testNewerCachedSupplementBeatsOlderBundled() async throws { - try writeSupplementCache(updatedAt: "2026-06-01", autoInput: 9) - - let store = makeStoreWithBundledSupplement(updatedAt: "2026-01-01", autoInput: 4) - let pricing = await store.current() - XCTAssertEqual(pricing.resolve(model: "auto")?.inputPerMillion, 9) - } - - /// An undated file never displaces a dated one, in either direction. - func testUndatedSupplementNeverDisplacesDatedOne() async throws { - try writeSupplementCache(updatedAt: nil, autoInput: 9) - - let datedBundle = await makeStoreWithBundledSupplement(updatedAt: "2026-06-01", autoInput: 4).current() - XCTAssertEqual(datedBundle.resolve(model: "auto")?.inputPerMillion, 4) - - try writeSupplementCache(updatedAt: "2026-06-01", autoInput: 9) - let undatedBundle = await makeStoreWithBundledSupplement(updatedAt: nil, autoInput: 4).current() - XCTAssertEqual(undatedBundle.resolve(model: "auto")?.inputPerMillion, 9) + func testSupplementSelectionPrefersTheMostRecentDatedSource() async throws { + let scenarios: [(name: String, bundled: String?, cached: String?, expected: Double)] = [ + ("newer bundle", "2026-06-01", "2026-01-01", 4), + ("same-day precise bundle", "2026-08-11T07:01:30Z", "2026-08-11", 4), + ("newer cache", "2026-01-01", "2026-06-01", 9), + ("undated cache", "2026-06-01", nil, 4), + ("undated bundle", nil, "2026-06-01", 9) + ] + + for scenario in scenarios { + try writeSupplementCache(updatedAt: scenario.cached, autoInput: 9) + let pricing = await makeStoreWithBundledSupplement(updatedAt: scenario.bundled, autoInput: 4).current() + XCTAssertEqual(pricing.resolve(model: "auto")?.inputPerMillion, scenario.expected, scenario.name) + } } private static func supplementJSON(updatedAt: String?, autoInput: Double) -> String { diff --git a/Tests/OpenUsageTests/ModelPricingTests.swift b/Tests/OpenUsageTests/ModelPricingTests.swift index 7882ac240..9e39e78e9 100644 --- a/Tests/OpenUsageTests/ModelPricingTests.swift +++ b/Tests/OpenUsageTests/ModelPricingTests.swift @@ -36,19 +36,19 @@ final class ModelPricingTests: XCTestCase { // MARK: - Resolution - func testExactMatchWins() throws { - let pricing = try makePricing(primary: ["gpt-5.5": rates(5, 30)]) - XCTAssertEqual(pricing.resolve(model: "gpt-5.5")?.inputPerMillion, 5) - } - - func testDateSuffixFuzzyMatch() throws { - let pricing = try makePricing(primary: ["claude-sonnet-4-20250514": rates(3, 15)]) - XCTAssertEqual(pricing.resolve(model: "claude-sonnet-4")?.inputPerMillion, 3) - } - - func testModelWithDateSuffixResolvesUndatedKey() throws { - let pricing = try makePricing(primary: ["claude-sonnet-4-5": rates(3, 15)]) - XCTAssertEqual(pricing.resolve(model: "claude-sonnet-4-5-20250929")?.inputPerMillion, 3) + func testModelResolutionNormalizesDatesProviderPrefixesAndSeparators() throws { + let scenarios: [(catalogKey: String, model: String, expected: Double)] = [ + ("gpt-5.5", "gpt-5.5", 5), + ("claude-sonnet-4-20250514", "claude-sonnet-4", 3), + ("claude-sonnet-4-5", "claude-sonnet-4-5-20250929", 3), + ("xai/grok-4.3", "grok-4.3", 1.25), + ("xai/grok-4.3", "grok-4-3", 1.25) + ] + + for scenario in scenarios { + let pricing = try makePricing(primary: [scenario.catalogKey: rates(scenario.expected, 15)]) + XCTAssertEqual(pricing.resolve(model: scenario.model)?.inputPerMillion, scenario.expected, scenario.model) + } } func testNumericVersionsDoNotConflate() throws { @@ -59,17 +59,6 @@ final class ModelPricingTests: XCTestCase { XCTAssertNil(reverse.resolve(model: "claude-sonnet-4-5")) } - func testProviderPrefixFuzzyMatch() throws { - let pricing = try makePricing(primary: ["xai/grok-4.3": rates(1.25, 2.5)]) - XCTAssertEqual(pricing.resolve(model: "grok-4.3")?.inputPerMillion, 1.25) - } - - func testSeparatorNormalizationMatch() throws { - // Log slug grok-4-3 (dashes) matches catalog key xai/grok-4.3 (dot). - let pricing = try makePricing(primary: ["xai/grok-4.3": rates(1.25, 2.5)]) - XCTAssertEqual(pricing.resolve(model: "grok-4-3")?.inputPerMillion, 1.25) - } - func testLongestKeyPreferred() throws { let pricing = try makePricing(primary: [ "gemini-3-pro": rates(1, 2), @@ -209,12 +198,21 @@ final class ModelPricingTests: XCTestCase { XCTAssertEqual(pricing.estimatedCostDollars(model: "claude-sonnet-4-5", tokens: tokens)!, 28.05, accuracy: 0.0001) } - func testCostAbove200kUsesHigherRateForWholeRequest() throws { + func testLongContextRatesApplyOnlyWhenPromptExceedsTheThreshold() throws { var entry = ModelRates(inputPerMillion: 3, outputPerMillion: 15, cacheWritePerMillion: 3.75, cacheReadPerMillion: 0.3) entry.inputAbove200kPerMillion = 6 + entry.outputAbove200kPerMillion = 22.5 let pricing = try makePricing(primary: ["claude-sonnet-4-5": entry]) - let tokens = TokenBreakdown(input: 300_000) - XCTAssertEqual(pricing.estimatedCostDollars(model: "claude-sonnet-4-5", tokens: tokens)!, 1.8, accuracy: 0.0001) + let scenarios: [(name: String, tokens: TokenBreakdown, expected: Double)] = [ + ("above threshold", TokenBreakdown(input: 300_000), 1.8), + ("exactly at threshold", TokenBreakdown(input: 200_000, output: 10_000), 0.75), + ("large output alone", TokenBreakdown(input: 10_000, output: 300_000), 4.53) + ] + + for scenario in scenarios { + let actual = try XCTUnwrap(pricing.estimatedCostDollars(model: "claude-sonnet-4-5", tokens: scenario.tokens)) + XCTAssertEqual(actual, scenario.expected, accuracy: 0.0001, scenario.name) + } } func testCombinedPromptBucketsSelectLongContextRatesForEveryBucket() throws { @@ -231,26 +229,6 @@ final class ModelPricingTests: XCTestCase { XCTAssertEqual(pricing.estimatedCostDollars(model: "claude-sonnet-4-5", tokens: tokens)!, expected, accuracy: 0.0001) } - func testLargeOutputAloneDoesNotSelectLongContextRates() throws { - var entry = ModelRates(inputPerMillion: 3, outputPerMillion: 15, cacheWritePerMillion: 3.75, cacheReadPerMillion: 0.3) - entry.inputAbove200kPerMillion = 6 - entry.outputAbove200kPerMillion = 22.5 - let pricing = try makePricing(primary: ["claude-sonnet-4-5": entry]) - let tokens = TokenBreakdown(input: 10_000, output: 300_000) - - XCTAssertEqual(pricing.estimatedCostDollars(model: "claude-sonnet-4-5", tokens: tokens)!, 4.53, accuracy: 0.0001) - } - - func testExactly200kPromptKeepsBaseRates() throws { - var entry = ModelRates(inputPerMillion: 3, outputPerMillion: 15, cacheWritePerMillion: 3.75, cacheReadPerMillion: 0.3) - entry.inputAbove200kPerMillion = 6 - entry.outputAbove200kPerMillion = 22.5 - let pricing = try makePricing(primary: ["claude-sonnet-4-5": entry]) - let tokens = TokenBreakdown(input: 200_000, output: 10_000) - - XCTAssertEqual(pricing.estimatedCostDollars(model: "claude-sonnet-4-5", tokens: tokens)!, 0.75, accuracy: 0.0001) - } - func testCustomLongContextThresholdUsesWholeRequestRates() { var entry = ModelRates( inputPerMillion: 5, diff --git a/Tests/OpenUsageTests/ModelUsageHoverTests.swift b/Tests/OpenUsageTests/ModelUsageHoverTests.swift index d9d11b49b..9afe43b7e 100644 --- a/Tests/OpenUsageTests/ModelUsageHoverTests.swift +++ b/Tests/OpenUsageTests/ModelUsageHoverTests.swift @@ -17,78 +17,44 @@ final class ModelUsageHoverTests: XCTestCase { XCTAssertEqual(decoded, line) } - func testDataStoreResolvesModelBreakdownOntoSpendRow() { - let provider = Provider(id: "claude", displayName: "Claude", icon: .providerMark("claude")) - let descriptor = WidgetDescriptor.spendTiles(provider: provider).first { $0.id == "claude.today" }! - let runtime = TestProviderRuntime( - provider: provider, - descriptors: [descriptor], - snapshot: ProviderSnapshot(providerID: provider.id, displayName: provider.displayName, lines: []) - ) - let store = WidgetDataStore( - registry: WidgetRegistry(providers: [provider], descriptors: [descriptor]), - providers: [runtime], - defaults: makeDefaults("resolve") - ) - store.snapshots[provider.id] = ProviderSnapshot( - providerID: provider.id, - displayName: provider.displayName, - lines: [ - .values( - label: "Today", - values: [ - MetricValue(number: 3, kind: .dollars, estimated: true), - MetricValue(number: 300, kind: .count, label: "tokens") - ], - modelBreakdown: sampleBreakdown() - ) - ] - ) - - let data = store.data(for: descriptor) - - XCTAssertTrue(data.hasModelBreakdown) - XCTAssertEqual(data.modelBreakdown?.models.map(\.model), ["alpha", "beta"]) - XCTAssertEqual(data.modelBreakdown?.sourceNote, "From test logs") - } + func testDataStoreResolvesSingleAndMultipleModelBreakdowns() { + let cases: [(providerID: String, breakdown: ModelUsageBreakdown, models: [String])] = [ + ("claude", sampleBreakdown(), ["alpha", "beta"]), + ("codex", ModelUsageBreakdown( + totalTokens: 300, totalCostUSD: 3, + models: [ModelUsageEntry(model: "gpt-5.5", totalTokens: 300, costUSD: 3)], + sourceNote: "From Codex test logs" + ), ["gpt-5.5"]) + ] - func testDataStoreAllowsSingleModelBreakdown() { - let provider = Provider(id: "codex", displayName: "Codex", icon: .providerMark("codex")) - let descriptor = WidgetDescriptor.spendTiles(provider: provider).first { $0.id == "codex.today" }! - let runtime = TestProviderRuntime( - provider: provider, - descriptors: [descriptor], - snapshot: ProviderSnapshot(providerID: provider.id, displayName: provider.displayName, lines: []) - ) - let store = WidgetDataStore( - registry: WidgetRegistry(providers: [provider], descriptors: [descriptor]), - providers: [runtime], - defaults: makeDefaults("single-model") - ) - store.snapshots[provider.id] = ProviderSnapshot( - providerID: provider.id, - displayName: provider.displayName, - lines: [ - .values( + for item in cases { + let provider = Provider( + id: item.providerID, displayName: item.providerID.capitalized, icon: .providerMark(item.providerID) + ) + let descriptor = WidgetDescriptor.spendTiles(provider: provider).first { $0.id == "\(provider.id).today" }! + let store = WidgetDataStore( + registry: WidgetRegistry(providers: [provider], descriptors: [descriptor]), + providers: [], + defaults: makeDefaults(item.providerID) + ) + store.snapshots[provider.id] = ProviderSnapshot( + providerID: provider.id, + displayName: provider.displayName, + lines: [.values( label: "Today", values: [ MetricValue(number: 3, kind: .dollars, estimated: true), MetricValue(number: 300, kind: .count, label: "tokens") ], - modelBreakdown: ModelUsageBreakdown( - totalTokens: 300, - totalCostUSD: 3, - models: [ModelUsageEntry(model: "gpt-5.5", totalTokens: 300, costUSD: 3)], - sourceNote: "From Codex test logs" - ) - ) - ] - ) - - let data = store.data(for: descriptor) - - XCTAssertTrue(data.hasModelBreakdown) - XCTAssertEqual(data.modelBreakdown?.models.map(\.model), ["gpt-5.5"]) + modelBreakdown: item.breakdown + )] + ) + + let data = store.data(for: descriptor) + XCTAssertTrue(data.hasModelBreakdown, item.providerID) + XCTAssertEqual(data.modelBreakdown?.models.map(\.model), item.models, item.providerID) + XCTAssertEqual(data.modelBreakdown?.sourceNote, item.breakdown.sourceNote, item.providerID) + } } func testWholePercentsAlwaysSumToOneHundred() { @@ -100,9 +66,6 @@ final class ModelUsageHoverTests: XCTestCase { XCTAssertEqual(ModelUsageDetail.wholePercents([1.0]), [100]) XCTAssertEqual(ModelUsageDetail.wholePercents([0, 0]), [0, 0], "an empty period stays all zero") - for shares in [[0.005, 0.005, 0.99], [0.2, 0.2, 0.2, 0.2, 0.2], [0.617, 0.337, 0.046]] { - XCTAssertEqual(ModelUsageDetail.wholePercents(shares).reduce(0, +), 100) - } } func testSharesUseCostWhenEveryModelIsPriced() { diff --git a/Tests/OpenUsageTests/OpenRouterProviderTests.swift b/Tests/OpenUsageTests/OpenRouterProviderTests.swift index ff24bc422..5026529b1 100644 --- a/Tests/OpenUsageTests/OpenRouterProviderTests.swift +++ b/Tests/OpenUsageTests/OpenRouterProviderTests.swift @@ -2,47 +2,21 @@ import XCTest @testable import OpenUsage final class OpenRouterAuthStoreTests: XCTestCase { - func testPrefersConfigFileOverEnvironment() { - // Config file wins so editing it to rotate the key isn't shadowed by a stale env value. - let store = OpenRouterAuthStore( - files: FakeFiles([OpenRouterAuthStore.configPaths[0]: #"{"apiKey":"sk-or-file"}"#]), - environment: FakeEnvironment(["OPENROUTER_API_KEY": "sk-or-env"]) - ) - - let auth = store.loadAPIKey() - - XCTAssertEqual(auth?.apiKey, "sk-or-file") - } - - func testFallsBackToEnvironmentWhenNoConfigFile() { - let store = OpenRouterAuthStore( - files: FakeFiles(), - environment: FakeEnvironment(["OPENROUTER_API_KEY": "sk-or-env"]) - ) - - let auth = store.loadAPIKey() - - XCTAssertEqual(auth?.apiKey, "sk-or-env") - } - - func testReadsKeyFromJSONConfigFile() { - let store = OpenRouterAuthStore( - files: FakeFiles([OpenRouterAuthStore.configPaths[0]: #"{ "api_key": "sk-or-json" }"#]), - environment: FakeEnvironment() - ) - - let auth = store.loadAPIKey() - - XCTAssertEqual(auth?.apiKey, "sk-or-json") - } - - func testReadsPlainTextKeyFile() { - let store = OpenRouterAuthStore( - files: FakeFiles([OpenRouterAuthStore.configPaths[1]: " sk-or-plain\n"]), - environment: FakeEnvironment() - ) - - XCTAssertEqual(store.loadAPIKey()?.apiKey, "sk-or-plain") + func testLoadsConfigFormatsBeforeEnvironmentAndSkipsBlankValues() { + let primary = OpenRouterAuthStore.configPaths[0] + let alternate = OpenRouterAuthStore.configPaths[1] + let cases: [(name: String, files: [String: String], environment: [String: String], expected: String)] = [ + ("saved override", [primary: #"{"apiKey":"sk-or-file"}"#], ["OPENROUTER_API_KEY": "sk-or-env"], "sk-or-file"), + ("environment fallback", [:], ["OPENROUTER_API_KEY": "sk-or-env"], "sk-or-env"), + ("legacy JSON key", [primary: #"{"api_key":"sk-or-json"}"#], [:], "sk-or-json"), + ("trimmed plain text", [alternate: " sk-or-plain\n"], [:], "sk-or-plain"), + ("blank config", [primary: " "], ["OPENROUTER_API_KEY": "sk-or-env"], "sk-or-env") + ] + + for entry in cases { + let store = OpenRouterAuthStore(files: FakeFiles(entry.files), environment: FakeEnvironment(entry.environment)) + XCTAssertEqual(store.loadAPIKey()?.apiKey, entry.expected, entry.name) + } } func testReturnsNilWhenNoKeyAnywhere() { @@ -50,15 +24,6 @@ final class OpenRouterAuthStoreTests: XCTestCase { XCTAssertNil(store.loadAPIKey()) } - func testIgnoresBlankConfigAndUsesEnvironment() { - let store = OpenRouterAuthStore( - files: FakeFiles([OpenRouterAuthStore.configPaths[0]: " "]), - environment: FakeEnvironment(["OPENROUTER_API_KEY": "sk-or-env"]) - ) - - XCTAssertEqual(store.loadAPIKey()?.apiKey, "sk-or-env") - } - // MARK: - In-app save / delete / status (Customize → OpenRouter → API Key) func testSaveAPIKeyWritesTrimmedJSONConfigFile() throws { @@ -102,16 +67,9 @@ final class OpenRouterAuthStoreTests: XCTestCase { XCTAssertEqual(OpenRouterAuthStore(files: FakeFiles(), environment: FakeEnvironment(envKey)).keyStatus(), .fromEnvironment) XCTAssertEqual(OpenRouterAuthStore(files: FakeFiles(file), environment: FakeEnvironment()).keyStatus(), .saved) XCTAssertEqual(OpenRouterAuthStore(files: FakeFiles(file), environment: FakeEnvironment(envKey)).keyStatus(), .overrideActive) - } - - func testKeyStatusOverrideActiveEvenWhenKeysMatch() { - // A saved key plus an env key is an override regardless of whether the values match — config - // wins, so the saved source is the one in use. (Same key in two places still reads Custom.) - let store = OpenRouterAuthStore( - files: FakeFiles([OpenRouterAuthStore.configPaths[0]: #"{"apiKey":"sk-or-same"}"#]), - environment: FakeEnvironment(["OPENROUTER_API_KEY": "sk-or-same"]) - ) - XCTAssertEqual(store.keyStatus(), .overrideActive) + XCTAssertEqual(OpenRouterAuthStore(files: FakeFiles(file), + environment: FakeEnvironment(["OPENROUTER_API_KEY": "sk-or-file"])).keyStatus(), + .overrideActive) } func testCurrentAPIKeyReturnsEffectiveKey() { @@ -122,27 +80,27 @@ final class OpenRouterAuthStoreTests: XCTestCase { XCTAssertEqual(store.currentAPIKey(), "sk-or-file") } - func testDeleteAPIKeyFallsBackToEnvironment() throws { - let files = FakeFiles([OpenRouterAuthStore.configPaths[0]: #"{"apiKey":"sk-or-file"}"#]) - let store = OpenRouterAuthStore(files: files, environment: FakeEnvironment(["OPENROUTER_API_KEY": "sk-or-env"])) - - XCTAssertEqual(store.keyStatus(), .overrideActive) - try store.deleteAPIKey() + func testDeleteClearsPrimaryAndAlternatePathsWithEnvironmentFallback() throws { + let cases: [(paths: [Int], environment: [String: String])] = [ + ([0], ["OPENROUTER_API_KEY": "sk-or-env"]), + ([0], [:]), + ([0, 1], [:]), + ([1], [:]) + ] - XCTAssertNil(files.files[OpenRouterAuthStore.configPaths[0]]) - XCTAssertEqual(store.keyStatus(), .fromEnvironment) - XCTAssertEqual(store.loadAPIKey()?.apiKey, "sk-or-env") - } - - func testDeleteAPIKeyBecomesNotSetWhenNoEnvKey() throws { - let files = FakeFiles([OpenRouterAuthStore.configPaths[0]: #"{"apiKey":"sk-or-file"}"#]) - let store = OpenRouterAuthStore(files: files, environment: FakeEnvironment()) + for entry in cases { + let contents = Dictionary(uniqueKeysWithValues: entry.paths.map { + (OpenRouterAuthStore.configPaths[$0], $0 == 0 ? #"{"apiKey":"sk-or-primary"}"# : "sk-or-alt") + }) + let files = FakeFiles(contents) + let store = OpenRouterAuthStore(files: files, environment: FakeEnvironment(entry.environment)) - try store.deleteAPIKey() + try store.deleteAPIKey() - XCTAssertNil(files.files[OpenRouterAuthStore.configPaths[0]]) - XCTAssertEqual(store.keyStatus(), .notSet) - XCTAssertNil(store.loadAPIKey()) + for path in OpenRouterAuthStore.configPaths { XCTAssertNil(files.files[path], path) } + XCTAssertEqual(store.loadAPIKey()?.apiKey, entry.environment["OPENROUTER_API_KEY"]) + XCTAssertEqual(store.keyStatus(), entry.environment.isEmpty ? .notSet : .fromEnvironment) + } } func testDeleteAPIKeyIsNoOpWhenFileMissing() throws { @@ -152,32 +110,6 @@ final class OpenRouterAuthStoreTests: XCTestCase { XCTAssertEqual(store.keyStatus(), .notSet) } - func testDeleteAPIKeyClearsAllConfigPaths() throws { - // A key in the alternate config path must also be cleared, or it resurfaces after the primary - // file is deleted and the Settings "clear" appears not to work. - let files = FakeFiles([ - OpenRouterAuthStore.configPaths[0]: #"{"apiKey":"sk-or-primary"}"#, - OpenRouterAuthStore.configPaths[1]: "sk-or-alt" - ]) - let store = OpenRouterAuthStore(files: files, environment: FakeEnvironment()) - - try store.deleteAPIKey() - - XCTAssertNil(files.files[OpenRouterAuthStore.configPaths[0]]) - XCTAssertNil(files.files[OpenRouterAuthStore.configPaths[1]]) - XCTAssertEqual(store.keyStatus(), .notSet) - } - - func testDeleteAPIKeyClearsAlternatePathOnly() throws { - let files = FakeFiles([OpenRouterAuthStore.configPaths[1]: "sk-or-alt"]) - let store = OpenRouterAuthStore(files: files, environment: FakeEnvironment()) - - XCTAssertEqual(store.keyStatus(), .saved) - try store.deleteAPIKey() - - XCTAssertNil(files.files[OpenRouterAuthStore.configPaths[1]]) - XCTAssertEqual(store.keyStatus(), .notSet) - } } final class OpenRouterUsageMapperTests: XCTestCase { diff --git a/Tests/OpenUsageTests/PaceNotificationLogicTests.swift b/Tests/OpenUsageTests/PaceNotificationLogicTests.swift index 1b61129eb..986b9ea60 100644 --- a/Tests/OpenUsageTests/PaceNotificationLogicTests.swift +++ b/Tests/OpenUsageTests/PaceNotificationLogicTests.swift @@ -42,13 +42,7 @@ final class PaceNotificationLogicTests: XCTestCase { XCTAssertTrue(first.fire.isEmpty) let second = step(close, from: first.newState) XCTAssertEqual(second.fire, [.healthyToClose]) - } - - func testStayingYellowDoesNotRefire() { - var state = step(healthy).newState - state = step(close, from: state).newState // fires - let again = step(close, from: state) // still yellow - XCTAssertTrue(again.fire.isEmpty) + XCTAssertTrue(step(close, from: second.newState).fire.isEmpty) } func testCloseToRunningOutFires() { @@ -81,6 +75,8 @@ final class PaceNotificationLogicTests: XCTestCase { // shouldn't spam alerts the moment the app opens. let first = step(running, fraction: 0.02) XCTAssertTrue(first.fire.isEmpty, "cold start primes, it doesn't fire") + XCTAssertTrue(step(.level(.critical), fraction: 0.01).fire.isEmpty) + XCTAssertTrue(step(.level(.normal), fraction: 0.99).fire.isEmpty) // A later worsening (after recovery) still fires normally. let recovered = step(healthy, fraction: 0.50, from: first.newState).newState let red = step(running, fraction: 0.02, from: recovered) @@ -102,28 +98,13 @@ final class PaceNotificationLogicTests: XCTestCase { XCTAssertEqual(refired.fire, [.healthyToClose]) } - func testResetJitterDoesNotRearmRunningOutAlert() { - var state = step(healthy).newState - state = step(running, from: state).newState // fires closeToRunningOut this window - - let jitteredReset = reset.addingTimeInterval(0.09) - let stillRunningOut = step(running, resetsAt: jitteredReset, from: state) - - XCTAssertTrue(stillRunningOut.fire.isEmpty) - XCTAssertEqual(stillRunningOut.newState.previousBucket, .runningOut) - XCTAssertEqual(stillRunningOut.newState.resetsAt, jitteredReset) - } - - func testResetJitterDoesNotRearmCloseAlert() { - var state = step(healthy).newState - state = step(close, from: state).newState // fires healthyToClose this window - + func testResetJitterDoesNotRearmExistingAlerts() { let jitteredReset = reset.addingTimeInterval(0.09) - let stillClose = step(close, resetsAt: jitteredReset, from: state) - - XCTAssertTrue(stillClose.fire.isEmpty) - XCTAssertEqual(stillClose.newState.previousBucket, .close) - XCTAssertEqual(stillClose.newState.resetsAt, jitteredReset) + for meterState in [close, running] { + let primed = step(healthy).newState + let fired = step(meterState, from: primed).newState + XCTAssertTrue(step(meterState, resetsAt: jitteredReset, from: fired).fire.isEmpty) + } } // MARK: - Recovery re-arms @@ -158,13 +139,6 @@ final class PaceNotificationLogicTests: XCTestCase { XCTAssertTrue(result.fire.isEmpty) } - func testLevelPrimesWithoutFiringOnFirstObservation() { - // `.level` has used/limit data but no pace projection. The first observation primes (records the - // under-10% baseline) without firing — like any other first observation. - let result = step(.level(.critical), fraction: 0.01) - XCTAssertTrue(result.fire.isEmpty) - } - func testLevelFiresAlmostOutUnderTenPercent() { // `.level` metrics still fire "Almost Out" on the under-10% edge — it's a remaining-based // trigger, not a pace one. No pace milestone fires for `.level`. @@ -186,9 +160,7 @@ final class PaceNotificationLogicTests: XCTestCase { // MARK: - Toggle gates - func testMasterOffSuppressionIsCallerSide() { - // The pure logic has no master flag; the caller gates it. With all per-triggers off, nothing - // fires even on a clear worsening — this stands in for the per-trigger-off path. + func testDisabledTriggersDoNotFire() { let off = PaceNotificationToggles(underTenPercent: false, healthyToClose: false, closeToRunningOut: false) let state = step(healthy, toggles: off).newState let close = step(self.close, fraction: 0.05, from: state, toggles: off) @@ -219,14 +191,4 @@ final class PaceNotificationLogicTests: XCTestCase { let refired = step(self.close, fraction: 0.20, from: state, toggles: on) XCTAssertTrue(refired.fire.contains(.healthyToClose)) } - - // MARK: - Fresh session window (treated as .level by the caller) - - func testFreshSessionLevelPrimesWithoutFiring() { - // A fresh session window resolves to an absolute-level state (`.level`) with plenty of quota - // left, so it primes without firing. (A `.level` metric can still fire "Almost Out" later if it - // drops under 10% — see testLevelFiresAlmostOutUnderTenPercent.) - let result = step(.level(.normal), fraction: 0.99) - XCTAssertTrue(result.fire.isEmpty) - } } diff --git a/Tests/OpenUsageTests/PaceTests.swift b/Tests/OpenUsageTests/PaceTests.swift index c17a28a70..f96954889 100644 --- a/Tests/OpenUsageTests/PaceTests.swift +++ b/Tests/OpenUsageTests/PaceTests.swift @@ -14,17 +14,6 @@ final class PaceTests: XCTestCase { now.addingTimeInterval(period * (1 - elapsed)) } - func testZeroUsageIsAhead() { - let reset = resetsAt(elapsed: 0.5, period: week) - XCTAssertEqual(Pace.evaluate(used: 0, limit: 100, resetsAt: reset, periodDuration: week, now: now)?.status, .ahead) - } - - func testAtOrOverLimitIsBehind() { - let reset = resetsAt(elapsed: 0.5, period: week) - XCTAssertEqual(Pace.evaluate(used: 100, limit: 100, resetsAt: reset, periodDuration: week, now: now)?.status, .behind) - XCTAssertEqual(Pace.evaluate(used: 130, limit: 100, resetsAt: reset, periodDuration: week, now: now)?.status, .behind) - } - func testEarlyInWindowStillProjectsPace() { let reset = resetsAt(elapsed: 0.02, period: week) XCTAssertEqual(Pace.evaluate(used: 5, limit: 100, resetsAt: reset, periodDuration: week, now: now)?.status, .behind) @@ -32,11 +21,18 @@ final class PaceTests: XCTestCase { func testAheadOnTrackBehindThresholds() { let reset = resetsAt(elapsed: 0.5, period: week) // half the window gone → projected = used * 2 - XCTAssertEqual(Pace.evaluate(used: 30, limit: 100, resetsAt: reset, periodDuration: week, now: now)?.status, .ahead) // 60 ≤ 90 - XCTAssertEqual(Pace.evaluate(used: 44, limit: 100, resetsAt: reset, periodDuration: week, now: now)?.status, .ahead) // 88 ≤ 90 - XCTAssertEqual(Pace.evaluate(used: 46, limit: 100, resetsAt: reset, periodDuration: week, now: now)?.status, .onTrack) // 92 in (90,100] - XCTAssertEqual(Pace.evaluate(used: 50, limit: 100, resetsAt: reset, periodDuration: week, now: now)?.status, .onTrack) // 100 lands exactly on the limit - XCTAssertEqual(Pace.evaluate(used: 60, limit: 100, resetsAt: reset, periodDuration: week, now: now)?.status, .behind) // 120 > 100 + let cases: [(used: Double, status: Pace.Status)] = [ + (0, .ahead), (44, .ahead), (46, .onTrack), + (50, .onTrack), (60, .behind), (100, .behind), (130, .behind) + ] + + for testCase in cases { + XCTAssertEqual( + Pace.evaluate(used: testCase.used, limit: 100, resetsAt: reset, periodDuration: week, now: now)?.status, + testCase.status, + "used: \(testCase.used)" + ) + } } func testEvaluateProjectsEndOfPeriodUsage() { @@ -55,12 +51,19 @@ final class PaceTests: XCTestCase { // MARK: MeterState (the view-facing projection of the pace verdict) - /// Half the window gone, `used` percent of 100 spent → projected = used * 2. - private func weeklyData(used: Double, displayMode: WidgetDisplayMode = .used) -> WidgetData { + private func pacedData( + used: Double, + elapsed: Double = 0.5, + period: TimeInterval? = nil, + displayMode: WidgetDisplayMode = .used, + alwaysShowPacing: Bool = false + ) -> WidgetData { + let period = period ?? week var data = WidgetData(title: "Weekly", icon: .providerMark("codex"), kind: .percent, used: used, limit: 100, displayMode: displayMode) - data.resetsAt = resetsAt(elapsed: 0.5, period: week) - data.periodDurationMs = Int(week * 1000) + data.resetsAt = resetsAt(elapsed: elapsed, period: period) + data.periodDurationMs = Int(period * 1000) + data.alwaysShowPacing = alwaysShowPacing return data } @@ -75,12 +78,11 @@ final class PaceTests: XCTestCase { return nil } - func testEvenPaceTickOnAmberAndRedInBothDisplayModes() { - // Half the window gone → even-pace tick at 0.5 in Used view, 0.5 in Left (mirror of elapsed). - XCTAssertEqual(tick(weeklyData(used: 46)) ?? 0, 0.5, accuracy: 0.001) - XCTAssertEqual(tick(weeklyData(used: 46, displayMode: .remaining)) ?? 0, 0.5, accuracy: 0.001) - XCTAssertEqual(tick(weeklyData(used: 60)) ?? 0, 0.5, accuracy: 0.001) - XCTAssertNil(tick(weeklyData(used: 30))) // blue hides tick by default + func testEvenPaceTickAppearsForAmberAndRed() { + // Half the window gone → the even-pace tick is 0.5. + XCTAssertEqual(tick(pacedData(used: 46)) ?? 0, 0.5, accuracy: 0.001) + XCTAssertEqual(tick(pacedData(used: 60)) ?? 0, 0.5, accuracy: 0.001) + XCTAssertNil(tick(pacedData(used: 30))) // blue hides tick by default } func testNoTickWithoutAResetWindow() { @@ -92,22 +94,22 @@ final class PaceTests: XCTestCase { } func testTooltipShowsNumericProjectionAtReset() { - XCTAssertEqual(weeklyData(used: 30).meterState(now: now).tooltip, "~40% left at reset") - XCTAssertEqual(weeklyData(used: 46).meterState(now: now).tooltip, "~92% used at reset") - XCTAssertEqual(weeklyData(used: 60).meterState(now: now).tooltip, "~20% over limit at reset") + XCTAssertEqual(pacedData(used: 30).meterState(now: now).tooltip, "~40% left at reset") + XCTAssertEqual(pacedData(used: 46).meterState(now: now).tooltip, "~92% used at reset") + XCTAssertEqual(pacedData(used: 60).meterState(now: now).tooltip, "~20% over limit at reset") } func testTooltipBlueCushionAtZeroUsage() { - XCTAssertEqual(weeklyData(used: 0).meterState(now: now).tooltip, "~100% left at reset") + XCTAssertEqual(pacedData(used: 0).meterState(now: now).tooltip, "~100% left at reset") } func testTooltipRedOverageFlooredToOnePercent() { - XCTAssertEqual(weeklyData(used: 50.2).meterState(now: now).tooltip, "~1% over limit at reset") + XCTAssertEqual(pacedData(used: 50.2).meterState(now: now).tooltip, "~1% over limit at reset") } func testSpentReadsLimitReached() { - XCTAssertEqual(weeklyData(used: 100).meterState(now: now), .spent) - XCTAssertEqual(weeklyData(used: 100).meterState(now: now).tooltip, "Limit reached") + XCTAssertEqual(pacedData(used: 100).meterState(now: now), .spent) + XCTAssertEqual(pacedData(used: 100).meterState(now: now).tooltip, "Limit reached") let nearlyEmpty = WidgetData(title: "Credits", icon: .providerMark("codex"), kind: .dollars, used: 99.999, limit: 100) XCTAssertEqual(nearlyEmpty.meterState(now: now), .spent) @@ -117,9 +119,9 @@ final class PaceTests: XCTestCase { } func testSpareCopyOnlyWhenAmber() { - XCTAssertEqual(spare(weeklyData(used: 46)), "~8% spare") - XCTAssertNil(spare(weeklyData(used: 30))) - XCTAssertNil(spare(weeklyData(used: 60))) + XCTAssertEqual(spare(pacedData(used: 46)), "~8% spare") + XCTAssertNil(spare(pacedData(used: 30))) + XCTAssertNil(spare(pacedData(used: 60))) } func testSpentOutranksCloseToLimitSoNoTickOrSpare() { @@ -132,33 +134,26 @@ final class PaceTests: XCTestCase { XCTAssertNil(spare(data)) } - func testProjectedToLandAtTheLimitPromotesToRed() { - let data = weeklyData(used: 49.8) - guard case .runningOut(let eta, _) = data.meterState(now: now) else { - return XCTFail("expected runningOut") - } - XCTAssertNil(eta) - XCTAssertNotNil(tick(data)) - XCTAssertNil(spare(data)) - XCTAssertEqual(data.meterState(now: now).tooltip, "~100% used at reset") - } - - func testProjectedExactlyAtLimitIsRedNotAmber() { - let reset = resetsAt(elapsed: 0.5, period: week) - XCTAssertEqual(Pace.evaluate(used: 50, limit: 100, resetsAt: reset, periodDuration: week, now: now)?.status, .onTrack) - guard case .runningOut(let eta, _) = weeklyData(used: 50).meterState(now: now) else { - return XCTFail("expected runningOut") + func testProjectionAtOrRoundedToLimitIsRedWithoutAnEta() { + for used in [49.8, 50] { + let data = pacedData(used: used) + guard case .runningOut(let eta, _) = data.meterState(now: now) else { + return XCTFail("expected runningOut for \(used)% used") + } + XCTAssertNil(eta) + XCTAssertNotNil(tick(data)) + XCTAssertNil(spare(data)) + XCTAssertEqual(data.meterState(now: now).tooltip, "~100% used at reset") } - XCTAssertNil(eta) } func testSmallButRealCushionStaysAmber() { - XCTAssertEqual(spare(weeklyData(used: 49)), "~2% spare") - XCTAssertNotNil(tick(weeklyData(used: 49))) + XCTAssertEqual(spare(pacedData(used: 49)), "~2% spare") + XCTAssertNotNil(tick(pacedData(used: 49))) } func testRunningOutCarriesAnEtaBeforeReset() { - guard case .runningOut(let eta, _) = weeklyData(used: 60).meterState(now: now) else { + guard case .runningOut(let eta, _) = pacedData(used: 60).meterState(now: now) else { return XCTFail("expected runningOut") } XCTAssertNotNil(eta) @@ -174,39 +169,20 @@ final class PaceTests: XCTestCase { func testPlentyRemainingSuppressesFalseRunOutFlame() { let session: TimeInterval = 5 * 3600 let elapsed = 240 / session // four minutes into a five-hour window - var data = WidgetData(title: "Session", icon: .providerMark("codex"), kind: .percent, - used: 2, limit: 100) - data.resetsAt = resetsAt(elapsed: elapsed, period: session) - data.periodDurationMs = Int(session * 1000) - XCTAssertEqual(Pace.evaluate(used: 2, limit: 100, - resetsAt: data.resetsAt!, - periodDuration: session, now: now)?.status, .behind) + let data = pacedData(used: 2, elapsed: elapsed, period: session) // Projection distrusted near-empty: a calm level bar, never a fabricated projection cushion. XCTAssertEqual(data.meterState(now: now), .level(.normal)) } func testOnePercentAtProjectionGateDoesNotBecomeRed() { let session: TimeInterval = 5 * 3600 - var data = WidgetData(title: "Session", icon: .providerMark("codex"), kind: .percent, - used: 1, limit: 100) - data.resetsAt = resetsAt(elapsed: 0.01, period: session) - data.periodDurationMs = Int(session * 1000) - XCTAssertEqual(Pace.evaluate(used: 1, limit: 100, - resetsAt: data.resetsAt!, - periodDuration: session, now: now)?.status, .onTrack) - XCTAssertEqual(data.meterState(now: now), .level(.normal)) + XCTAssertEqual(pacedData(used: 1, elapsed: 0.01, period: session).meterState(now: now), .level(.normal)) } func testRunOutFlameShowsOnceFivePercentUsedDespiteHighRemaining() { let session: TimeInterval = 5 * 3600 let elapsed = 240 / session - var data = WidgetData(title: "Session", icon: .providerMark("codex"), kind: .percent, - used: 6, limit: 100) - data.resetsAt = resetsAt(elapsed: elapsed, period: session) - data.periodDurationMs = Int(session * 1000) - XCTAssertEqual(Pace.evaluate(used: 6, limit: 100, - resetsAt: data.resetsAt!, - periodDuration: session, now: now)?.status, .behind) + let data = pacedData(used: 6, elapsed: elapsed, period: session) guard case .runningOut = data.meterState(now: now) else { return XCTFail("expected runningOut when burning fast with ≥5% used") } @@ -221,20 +197,6 @@ final class PaceTests: XCTestCase { // MARK: Always Show Pacing (opt-in tick + healthy copy on blue) - private func pacedData(used: Double, elapsed: Double, displayMode: WidgetDisplayMode = .used, - alwaysShowPacing: Bool = false) -> WidgetData { - var data = WidgetData(title: "Weekly", icon: .providerMark("codex"), kind: .percent, - used: used, limit: 100, displayMode: displayMode) - data.resetsAt = resetsAt(elapsed: elapsed, period: week) - data.periodDurationMs = Int(week * 1000) - data.alwaysShowPacing = alwaysShowPacing - return data - } - - func testHealthyBarHasNoTickByDefault() { - XCTAssertNil(tick(pacedData(used: 30, elapsed: 0.4))) - } - func testAlwaysShowPacingAddsEvenPaceTickToHealthyBar() { XCTAssertEqual(tick(pacedData(used: 30, elapsed: 0.4, alwaysShowPacing: true)) ?? -1, 0.4, accuracy: 0.001) @@ -243,12 +205,6 @@ final class PaceTests: XCTestCase { 0.6, accuracy: 0.001) } - func testEvenPaceNotchInLeftViewSitsInsideTheFill() { - XCTAssertEqual(tick(pacedData(used: 2, elapsed: 0.30, displayMode: .remaining, - alwaysShowPacing: true)) ?? -1, - 0.70, accuracy: 0.001) - } - func testAmberTickIsAlwaysEvenPaceLine() { XCTAssertEqual(tick(pacedData(used: 46, elapsed: 0.5)) ?? -1, 0.5, accuracy: 0.001) XCTAssertEqual(tick(pacedData(used: 46, elapsed: 0.5, alwaysShowPacing: true)) ?? -1, diff --git a/Tests/OpenUsageTests/PanelOutsideClickPolicyTests.swift b/Tests/OpenUsageTests/PanelOutsideClickPolicyTests.swift index e60c989c6..71dc513ad 100644 --- a/Tests/OpenUsageTests/PanelOutsideClickPolicyTests.swift +++ b/Tests/OpenUsageTests/PanelOutsideClickPolicyTests.swift @@ -13,6 +13,8 @@ final class PanelOutsideClickPolicyTests: XCTestCase { .init(isOnStatusButton: true), .init(isPanelWindow: true), .init(isStatusItemWindow: true), + .init(isInsidePanel: true), + .init(isMorphing: true, isInsidePanel: true), .init(eventWindowTypeName: "NSMenuWindow"), .init(eventWindowTypeName: "_NSPopoverWindow"), ] @@ -22,27 +24,10 @@ final class PanelOutsideClickPolicyTests: XCTestCase { } } - func testPopoverWindowMatchIsCaseInsensitive() { - // A click inside a hover popover (its own `_NSPopoverWindow`, floating outside the panel frame) - // must keep the panel open so interactive controls in it — the resets "Use" button — receive - // the click instead of being dismissed as an outside click. - XCTAssertTrue( - PanelOutsideClickPolicy.shouldKeepOpen(.init(eventWindowTypeName: "myPOPOVERwindow")) - ) - } - - func testInsidePanelKeepsOpenWithoutAnEventWindow() { - XCTAssertTrue(PanelOutsideClickPolicy.shouldKeepOpen(.init(isInsidePanel: true))) - } - - func testInsidePanelStillKeepsOpenWhenAnotherReasonAlsoApplies() { - XCTAssertTrue(PanelOutsideClickPolicy.shouldKeepOpen(.init(isMorphing: true, isInsidePanel: true))) - } - - func testMenuWindowMatchIsCaseInsensitive() { - XCTAssertTrue( - PanelOutsideClickPolicy.shouldKeepOpen(.init(eventWindowTypeName: "privateMENUwindow")) - ) + func testMenuAndPopoverWindowMatchesAreCaseInsensitive() { + for windowType in ["myPOPOVERwindow", "privateMENUwindow"] { + XCTAssertTrue(PanelOutsideClickPolicy.shouldKeepOpen(.init(eventWindowTypeName: windowType))) + } } func testUnrelatedWindowDismisses() { @@ -56,17 +41,6 @@ final class PanelOutsideClickPolicyTests: XCTestCase { private let buttonFrame = NSRect(x: 100, y: 972, width: 40, height: 24) private let screenTop: CGFloat = 1000 - func testClickAtTopOfScreenHitsStatusButton() { - // The issue #1008 geometry, live-captured: with the cursor pinned to the top of the screen, - // `NSEvent.mouseLocation.y` reports exactly the screen's maxY — a few points *above* the - // button frame's top, in the menu-bar strip macOS still routes to the button. Reading it as - // an outside click dismissed the panel on mouse-down, and the button's mouse-up toggle - // reopened it, so the second click never closed the panel. - XCTAssertTrue(PanelOutsideClickPolicy.pointHitsStatusButton( - NSPoint(x: 120, y: screenTop), buttonFrame: buttonFrame, screenTop: screenTop - )) - } - func testClickAtTopOfScreenWithRealCapturedGeometryHits() { // Verbatim from the diagnostic log that pinned the bug down: point {4122.98, 1555}, // buttonFrame {{4061, 1529}, {242.5, 22}}, screen {{1728, -65}, {2880, 1620}} (maxY 1555). @@ -89,27 +63,17 @@ final class PanelOutsideClickPolicyTests: XCTestCase { )) } - func testClickBesideStatusButtonMisses() { - XCTAssertFalse(PanelOutsideClickPolicy.pointHitsStatusButton( - NSPoint(x: 99, y: 984), buttonFrame: buttonFrame, screenTop: screenTop - )) - XCTAssertFalse(PanelOutsideClickPolicy.pointHitsStatusButton( - NSPoint(x: 141, y: 984), buttonFrame: buttonFrame, screenTop: screenTop - )) - } - - func testClickInTopStripBesideStatusButtonMisses() { - // The upward extension widens the hit zone only vertically — a top-edge click next to the - // button (over a neighboring status item) must still dismiss. - XCTAssertFalse(PanelOutsideClickPolicy.pointHitsStatusButton( - NSPoint(x: 150, y: screenTop), buttonFrame: buttonFrame, screenTop: screenTop - )) - } - - func testClickBelowStatusButtonMisses() { - XCTAssertFalse(PanelOutsideClickPolicy.pointHitsStatusButton( - NSPoint(x: 120, y: 971), buttonFrame: buttonFrame, screenTop: screenTop - )) + func testClicksOutsideButtonEdgesAndTopStripMiss() { + for point in [ + NSPoint(x: 99, y: 984), + NSPoint(x: 141, y: 984), + NSPoint(x: 150, y: screenTop), + NSPoint(x: 120, y: 971) + ] { + XCTAssertFalse(PanelOutsideClickPolicy.pointHitsStatusButton( + point, buttonFrame: buttonFrame, screenTop: screenTop + )) + } } func testEmptyButtonFrameNeverHits() { diff --git a/Tests/OpenUsageTests/PopoverTransparencyStoreTests.swift b/Tests/OpenUsageTests/PopoverTransparencyStoreTests.swift index 813517d0d..5437c5d80 100644 --- a/Tests/OpenUsageTests/PopoverTransparencyStoreTests.swift +++ b/Tests/OpenUsageTests/PopoverTransparencyStoreTests.swift @@ -21,24 +21,14 @@ final class PopoverTransparencyStoreTests: XCTestCase { increaseContrast: increaseContrast) } - func testIncreaseTransparencyDefaultsOff() { - let store = PopoverTransparencyStore(defaults: makeDefaults("default")) + func testIncreaseTransparencyDefaultsOffAndPersistsBothTransitions() { + let defaults = makeDefaults("persist") + let store = PopoverTransparencyStore(defaults: defaults) XCTAssertFalse(store.increaseTransparency) - } - func testIncreaseTransparencyPersists() { - let defaults = makeDefaults("persist") - PopoverTransparencyStore(defaults: defaults).increaseTransparency = true - // A fresh store reading the same defaults sees the saved value. + store.increaseTransparency = true XCTAssertTrue(PopoverTransparencyStore(defaults: defaults).increaseTransparency) - } - func testIncreaseTransparencyTogglesBackOffAndPersists() { - // The normal 2 -> 1 direction: turning the base off again writes through (exercises the no-op - // didSet guard in both directions) and a relaunch reads it back as off. - let defaults = makeDefaults("toggleBack") - let store = PopoverTransparencyStore(defaults: defaults) - store.increaseTransparency = true store.increaseTransparency = false XCTAssertFalse(PopoverTransparencyStore(defaults: defaults).increaseTransparency) } @@ -66,23 +56,21 @@ final class PopoverTransparencyStoreTests: XCTestCase { // MARK: - Party Mode toggle / state machine (Normal 1, Increase Transparency 2, Party 3, Drunk 4) - func testPartyModeToggleMirrorsTheEgg() { - let store = makeStore("partyMirror") - XCTAssertFalse(store.partyModeActive) - store.toggleSecretCode() // cheat code in - XCTAssertTrue(store.partyModeActive, "Party Mode reads the egg state") - store.partyModeActive = false // toggle off == exit - XCTAssertFalse(store.secretCodeActive) - } + func testPartyModeReturnsToEitherPriorBaseStyle() { + for increased in [false, true] { + let store = makeStore(increased ? "partyIncreased" : "partyOpaque") + store.increaseTransparency = increased + XCTAssertFalse(store.partyModeActive) - func testPartyToggleOffFromState3ReturnsToBase() { - // Base 1 (Increase Transparency off): 1 -> 3 -> 1. Egg off + base off is opaque on any host. - let store = makeStore("p3base1") - store.toggleSecretCode() // 1 -> 3 - XCTAssertEqual(store.effectiveStyle, .party) - store.partyModeActive = false // 3 -> 1 - XCTAssertFalse(store.secretCodeActive) - XCTAssertEqual(store.effectiveStyle, .opaque) + store.toggleSecretCode() + XCTAssertTrue(store.partyModeActive) + XCTAssertEqual(store.effectiveStyle, .party) + + store.partyModeActive = false + XCTAssertFalse(store.secretCodeActive) + XCTAssertEqual(store.increaseTransparency, increased) + XCTAssertEqual(store.effectiveStyle, increased ? .increased : .opaque) + } } func testPartyToggleOffFromState4ClearsDrunkAndReturnsToBase() { @@ -107,18 +95,6 @@ final class PopoverTransparencyStoreTests: XCTestCase { XCTAssertEqual(store.effectiveStyle, .party) } - func testBase2PartyRendersAndReturnsToIncreaseTransparency() { - // Direct 2 -> 3 -> 2: the egg renders the readable party with base 2 (deterministic here because - // the store pins the accessibility flags off), and exiting restores base 2. - let store = makeStore("base2party") - store.increaseTransparency = true // base 2 - store.toggleSecretCode() // 2 -> 3 - XCTAssertEqual(store.effectiveStyle, .party) - store.partyModeActive = false // 3 -> 2 - XCTAssertFalse(store.secretCodeActive) - XCTAssertTrue(store.increaseTransparency, "base 2 restored") - } - func testBaseStateIsRememberedAcrossTheEgg() { // Older state memory: Increase Transparency (base 2) survives the whole 2 -> 3 -> 4 -> 2 round // trip untouched, because its Settings toggle is frozen while the egg runs. (Asserts the stored @@ -147,22 +123,19 @@ final class PopoverTransparencyStoreTests: XCTestCase { // MARK: - Accessibility clamp (the egg yields to Reduce Transparency / Increase Contrast) - func testEggYieldsToReduceTransparency() { - // With Reduce Transparency on, entering the code keeps the panel opaque — the egg may not turn it - // translucent — and escalating to Drunk Mode doesn't change that. - let store = makeStore("eggA11yReduce", reduceTransparency: true) - store.toggleSecretCode() - XCTAssertTrue(store.secretCodeActive, "the egg is active as state") - XCTAssertEqual(store.effectiveStyle, .opaque, "but it renders opaque, yielding to the flag") - XCTAssertEqual(store.surfaceTreatment, .opaque) - store.drunkMode = true - XCTAssertEqual(store.effectiveStyle, .opaque, "drunk is clamped too — no window fade") - } + func testEggYieldsToEitherAccessibilitySetting() { + for store in [ + makeStore("eggA11yReduce", reduceTransparency: true), + makeStore("eggA11yContrast", increaseContrast: true) + ] { + store.toggleSecretCode() + XCTAssertTrue(store.secretCodeActive) + XCTAssertEqual(store.effectiveStyle, .opaque) + XCTAssertEqual(store.surfaceTreatment, .opaque) - func testEggYieldsToIncreaseContrast() { - let store = makeStore("eggA11yContrast", increaseContrast: true) - store.toggleSecretCode() - XCTAssertEqual(store.effectiveStyle, .opaque) + store.drunkMode = true + XCTAssertEqual(store.effectiveStyle, .opaque) + } } func testPartyPausedReflectsAccessibility() { diff --git a/Tests/OpenUsageTests/PopoverTransparencyStyleTests.swift b/Tests/OpenUsageTests/PopoverTransparencyStyleTests.swift index 87f464517..e046ec757 100644 --- a/Tests/OpenUsageTests/PopoverTransparencyStyleTests.swift +++ b/Tests/OpenUsageTests/PopoverTransparencyStyleTests.swift @@ -6,8 +6,13 @@ import XCTest /// the secret-code egg yield to them. With the flags off the egg wins — the secret code is the readable /// `party`, "Drunk Mode" the barely-readable `drunk`. final class PopoverTransparencyStyleTests: XCTestCase { - private func resolve(increase: Bool, secretCode: Bool, drunkMode: Bool, - reduceTransparency: Bool, increaseContrast: Bool) -> PopoverTransparencyStyle { + private func resolve( + increase: Bool = false, + secretCode: Bool = false, + drunkMode: Bool = false, + reduceTransparency: Bool = false, + increaseContrast: Bool = false + ) -> PopoverTransparencyStyle { PopoverTransparencyStyle.resolve( increaseTransparency: increase, secretCodeActive: secretCode, @@ -17,65 +22,27 @@ final class PopoverTransparencyStyleTests: XCTestCase { ) } - func testDefaultIsOpaque() { - XCTAssertEqual(resolve(increase: false, secretCode: false, drunkMode: false, - reduceTransparency: false, increaseContrast: false), .opaque) + func testBaseAndPartyStylesFollowSecretCodePrecedence() { + XCTAssertEqual(resolve(), .opaque) + XCTAssertEqual(resolve(increase: true), .increased) + XCTAssertEqual(resolve(secretCode: true), .party) + XCTAssertEqual(resolve(secretCode: true, drunkMode: true), .drunk) + XCTAssertEqual(resolve(drunkMode: true), .opaque) + XCTAssertEqual(resolve(increase: true, drunkMode: true), .increased) + XCTAssertEqual(resolve(increase: true, secretCode: true), .party) + XCTAssertEqual(resolve(increase: true, secretCode: true, drunkMode: true), .drunk) } - func testProperToggleIncreasesWhenNoSystemFlags() { - XCTAssertEqual(resolve(increase: true, secretCode: false, drunkMode: false, - reduceTransparency: false, increaseContrast: false), .increased) - } - - func testProperToggleYieldsToReduceTransparency() { - XCTAssertEqual(resolve(increase: true, secretCode: false, drunkMode: false, - reduceTransparency: true, increaseContrast: false), .opaque) - } - - func testProperToggleYieldsToIncreaseContrast() { - XCTAssertEqual(resolve(increase: true, secretCode: false, drunkMode: false, - reduceTransparency: false, increaseContrast: true), .opaque) - } - - func testSecretCodeIsPartyEvenWhenProperToggleIsOff() { - XCTAssertEqual(resolve(increase: false, secretCode: true, drunkMode: false, - reduceTransparency: false, increaseContrast: false), .party) - } - - func testDrunkModeIsDrunk() { - XCTAssertEqual(resolve(increase: false, secretCode: true, drunkMode: true, - reduceTransparency: false, increaseContrast: false), .drunk) - } - - func testDrunkModeIsIgnoredWithoutTheSecretCode() { - // Drunk can't exist without the party: with the code off, drunkMode is ignored entirely and the - // resolved style is just the base (opaque or increased), never .drunk. - XCTAssertEqual(resolve(increase: false, secretCode: false, drunkMode: true, - reduceTransparency: false, increaseContrast: false), .opaque) - XCTAssertEqual(resolve(increase: true, secretCode: false, drunkMode: true, - reduceTransparency: false, increaseContrast: false), .increased) - } - - func testEggYieldsToAccessibilityFlags() { - // Reduce Transparency / Increase Contrast are accessibility needs, so they clamp the panel to - // opaque even when the secret code (and Drunk Mode) is active — the hidden egg may not override - // them. Either flag alone is enough. - XCTAssertEqual(resolve(increase: false, secretCode: true, drunkMode: false, - reduceTransparency: true, increaseContrast: false), .opaque) - XCTAssertEqual(resolve(increase: false, secretCode: true, drunkMode: false, - reduceTransparency: false, increaseContrast: true), .opaque) - XCTAssertEqual(resolve(increase: false, secretCode: true, drunkMode: true, - reduceTransparency: true, increaseContrast: false), .opaque) - XCTAssertEqual(resolve(increase: true, secretCode: true, drunkMode: true, - reduceTransparency: true, increaseContrast: true), .opaque) - } - - func testEggRendersWhenAccessibilityFlagsAreOff() { - // With both flags off the egg still wins over the proper toggle. - XCTAssertEqual(resolve(increase: true, secretCode: true, drunkMode: false, - reduceTransparency: false, increaseContrast: false), .party) - XCTAssertEqual(resolve(increase: true, secretCode: true, drunkMode: true, - reduceTransparency: false, increaseContrast: false), .drunk) + func testEitherAccessibilitySettingOverridesEveryTranslucentStyle() { + for (reduce, contrast) in [(true, false), (false, true), (true, true)] { + XCTAssertEqual(resolve(increase: true, reduceTransparency: reduce, increaseContrast: contrast), .opaque) + XCTAssertEqual(resolve(secretCode: true, reduceTransparency: reduce, increaseContrast: contrast), .opaque) + XCTAssertEqual( + resolve(increase: true, secretCode: true, drunkMode: true, + reduceTransparency: reduce, increaseContrast: contrast), + .opaque + ) + } } func testSurfaceTreatmentPerStyle() { diff --git a/Tests/OpenUsageTests/PricingBundledResourceTests.swift b/Tests/OpenUsageTests/PricingBundledResourceTests.swift index 294f78160..fc56c4604 100644 --- a/Tests/OpenUsageTests/PricingBundledResourceTests.swift +++ b/Tests/OpenUsageTests/PricingBundledResourceTests.swift @@ -36,36 +36,27 @@ final class PricingBundledResourceTests: XCTestCase { /// now against live catalogs — update the constants if the providers themselves reprice). func testKnownCursorSlugsPriceCorrectly() { let pricing = Self.pricing - XCTAssertEqual(pricing.resolve(model: "auto")?.inputPerMillion, 1.25) - XCTAssertEqual(pricing.resolve(model: "claude-4.5-sonnet-thinking")?.inputPerMillion, 3) - XCTAssertEqual(pricing.resolve(model: "claude-4.6-opus-max-thinking")?.inputPerMillion, 5) - XCTAssertEqual(pricing.resolve(model: "claude-4.6-opus-max-thinking-fast")?.inputPerMillion, 30) - XCTAssertEqual(pricing.resolve(model: "gpt-5.5-xhigh-fast")?.inputPerMillion, 12.5) - XCTAssertEqual(pricing.resolve(model: "gpt-5.6-sol-ultra")?.inputPerMillion, 5) - XCTAssertEqual(pricing.resolve(model: "gpt-5.6-sol-ultra-fast")?.inputPerMillion, 10) - XCTAssertEqual(pricing.resolve(model: "gpt-5.6-terra-high")?.inputPerMillion, 2) - XCTAssertEqual(pricing.resolve(model: "gpt-5.6-terra-high-fast")?.inputPerMillion, 4) - XCTAssertEqual(pricing.resolve(model: "gpt-5.6-luna")?.inputPerMillion, 0.2) - XCTAssertEqual(pricing.resolve(model: "gpt-5.6-luna-fast")?.inputPerMillion, 0.4) - XCTAssertEqual(pricing.resolve(model: "gemini-3.6-flash-high")?.inputPerMillion, 1.5) - XCTAssertEqual(pricing.resolve(model: "gemini-3.7-flash-high")?.inputPerMillion, 0.75) + let expectedInputRates: [(String, Double)] = [ + ("auto", 1.25), ("claude-4.5-sonnet-thinking", 3), + ("claude-4.6-opus-max-thinking", 5), ("claude-4.6-opus-max-thinking-fast", 30), + ("gpt-5.5-xhigh-fast", 12.5), + ("gpt-5.6-sol-ultra", 5), ("gpt-5.6-sol-ultra-fast", 10), + ("gpt-5.6-terra-high", 2), ("gpt-5.6-terra-high-fast", 4), + ("gpt-5.6-luna", 0.2), ("gpt-5.6-luna-fast", 0.4), + ("gemini-3.6-flash-high", 1.5), ("gemini-3.7-flash-high", 0.75), + ("grok-4-20-thinking", 2), ("grok-4.5", 2), + ("grok-4.5-fast-high", 4), ("grok-4.5-high-fast", 4), + ("cursor-grok-4.5-high-fast", 4), ("cursor-grok-4.6-high", 2), + ("cursor-grok-4.6-high-fast", 4), ("grok-4-6-xhigh", 2), + ("grok-4-6-xhigh-fast", 4), ("kimi-k2p5", 0.6), + ("kimi-k2.7-code", 0.95), ("kimi-k2p7", 0.95), ("kimi-k3-max", 3), + ("claude-4.7-opus-high-thinking", 5), ("claude-4.7-opus-max-thinking-fast", 30), + ("glm-5.2-max", 1.4) + ] + for (model, expected) in expectedInputRates { + XCTAssertEqual(pricing.resolve(model: model)?.inputPerMillion, expected, model) + } XCTAssertEqual(pricing.resolve(model: "gemini-3.7-flash-high")?.outputPerMillion, 3.75) - XCTAssertEqual(pricing.resolve(model: "grok-4-20-thinking")?.inputPerMillion, 2) - XCTAssertEqual(pricing.resolve(model: "grok-4.5")?.inputPerMillion, 2) - XCTAssertEqual(pricing.resolve(model: "grok-4.5-fast-high")?.inputPerMillion, 4) - XCTAssertEqual(pricing.resolve(model: "grok-4.5-high-fast")?.inputPerMillion, 4) - XCTAssertEqual(pricing.resolve(model: "cursor-grok-4.5-high-fast")?.inputPerMillion, 4) - XCTAssertEqual(pricing.resolve(model: "cursor-grok-4.6-high")?.inputPerMillion, 2) - XCTAssertEqual(pricing.resolve(model: "cursor-grok-4.6-high-fast")?.inputPerMillion, 4) - XCTAssertEqual(pricing.resolve(model: "grok-4-6-xhigh")?.inputPerMillion, 2) - XCTAssertEqual(pricing.resolve(model: "grok-4-6-xhigh-fast")?.inputPerMillion, 4) - XCTAssertEqual(pricing.resolve(model: "kimi-k2p5")?.inputPerMillion, 0.6) - XCTAssertEqual(pricing.resolve(model: "kimi-k2.7-code")?.inputPerMillion, 0.95) - XCTAssertEqual(pricing.resolve(model: "kimi-k2p7")?.inputPerMillion, 0.95) - XCTAssertEqual(pricing.resolve(model: "kimi-k3-max")?.inputPerMillion, 3) - XCTAssertEqual(pricing.resolve(model: "claude-4.7-opus-high-thinking")?.inputPerMillion, 5) - XCTAssertEqual(pricing.resolve(model: "claude-4.7-opus-max-thinking-fast")?.inputPerMillion, 30) - XCTAssertEqual(pricing.resolve(model: "glm-5.2-max")?.inputPerMillion, 1.4) XCTAssertEqual(pricing.resolve(model: "github_bugbot")?.outputPerMillion, 30) XCTAssertEqual(pricing.resolve(model: "Premium (GPT-5.3-Codex)")?.inputPerMillion, 1.75) } @@ -238,38 +229,22 @@ final class PricingBundledResourceTests: XCTestCase { func testGPT56PricingAndAliases() throws { let pricing = Self.pricing - let sol = try XCTUnwrap(pricing.resolve(model: "gpt-5.6-sol-ultra")) - XCTAssertEqual(sol.inputPerMillion, 5.0) - XCTAssertEqual(sol.cacheWritePerMillion, 6.25) - XCTAssertEqual(sol.cacheReadPerMillion, 0.5) - XCTAssertEqual(sol.outputPerMillion, 30.0) - let solFast = try XCTUnwrap(pricing.resolve(model: "gpt-5.6-sol-ultra-fast")) - XCTAssertEqual(solFast.inputPerMillion, 10.0) - XCTAssertEqual(solFast.cacheWritePerMillion, 12.5) - XCTAssertEqual(solFast.cacheReadPerMillion, 1.0) - XCTAssertEqual(solFast.outputPerMillion, 60.0) - - let terra = try XCTUnwrap(pricing.resolve(model: "gpt-5.6-terra-high")) - XCTAssertEqual(terra.inputPerMillion, 2.0) - XCTAssertEqual(terra.cacheWritePerMillion, 2.5) - XCTAssertEqual(terra.cacheReadPerMillion, 0.2) - XCTAssertEqual(terra.outputPerMillion, 12.0) - let terraFast = try XCTUnwrap(pricing.resolve(model: "gpt-5.6-terra-high-fast")) - XCTAssertEqual(terraFast.inputPerMillion, 4.0) - XCTAssertEqual(terraFast.cacheWritePerMillion, 5.0) - XCTAssertEqual(terraFast.cacheReadPerMillion, 0.4) - XCTAssertEqual(terraFast.outputPerMillion, 24.0) - - let luna = try XCTUnwrap(pricing.resolve(model: "gpt-5.6-luna")) - XCTAssertEqual(luna.inputPerMillion, 0.2) - XCTAssertEqual(luna.cacheWritePerMillion, 0.25) - XCTAssertEqual(luna.cacheReadPerMillion, 0.02) - XCTAssertEqual(luna.outputPerMillion, 1.2) - let lunaFast = try XCTUnwrap(pricing.resolve(model: "gpt-5.6-luna-fast")) - XCTAssertEqual(lunaFast.inputPerMillion, 0.4) - XCTAssertEqual(lunaFast.cacheWritePerMillion, 0.5) - XCTAssertEqual(lunaFast.cacheReadPerMillion, 0.04) - XCTAssertEqual(lunaFast.outputPerMillion, 2.4) + let expectedRates: [(String, [Double])] = [ + ("gpt-5.6-sol-ultra", [5, 6.25, 0.5, 30]), + ("gpt-5.6-sol-ultra-fast", [10, 12.5, 1, 60]), + ("gpt-5.6-terra-high", [2, 2.5, 0.2, 12]), + ("gpt-5.6-terra-high-fast", [4, 5, 0.4, 24]), + ("gpt-5.6-luna", [0.2, 0.25, 0.02, 1.2]), + ("gpt-5.6-luna-fast", [0.4, 0.5, 0.04, 2.4]) + ] + for (model, expected) in expectedRates { + let actual = try XCTUnwrap(pricing.resolve(model: model)) + XCTAssertEqual( + [actual.inputPerMillion, actual.cacheWritePerMillion, actual.cacheReadPerMillion, actual.outputPerMillion], + expected, + model + ) + } } /// Opus 4.7/4.8 fast modes: Cursor's published rates (supplement overrides) win over the @@ -312,66 +287,40 @@ final class PricingBundledResourceTests: XCTestCase { XCTAssertEqual(pricing.resolve(model: "grok-composer-2.5-fast")?.inputPerMillion, 3) } - /// Grok 4.5 (Cursor + SpaceXAI first-party): standard and fast rates from Cursor docs, with - /// effort slugs collapsing to the same entries. - func testGrok45PricingAndAliases() throws { + /// Both first-party Grok versions share rates and accept effort, separator, and Cursor-prefix variants. + func testGrokPricingAndAliases() throws { let pricing = Self.pricing - let standard = try XCTUnwrap(pricing.resolve(model: "grok-4.5-high")) - XCTAssertEqual(standard.inputPerMillion, 2.0) - XCTAssertEqual(standard.cacheWritePerMillion, 2.0) - XCTAssertEqual(standard.cacheReadPerMillion, 0.5) - XCTAssertEqual(standard.outputPerMillion, 6.0) - XCTAssertEqual(pricing.resolve(model: "grok-4.5"), standard) - XCTAssertEqual(pricing.resolve(model: "grok-4.5-build"), standard) - XCTAssertEqual(pricing.resolve(model: "grok-4.5-low"), standard) - - let fast = try XCTUnwrap(pricing.resolve(model: "grok-4.5-fast")) - XCTAssertEqual(fast.inputPerMillion, 4.0) - XCTAssertEqual(fast.cacheWritePerMillion, 4.0) - XCTAssertEqual(fast.cacheReadPerMillion, 1.0) - XCTAssertEqual(fast.outputPerMillion, 12.0) - // Cursor CSV uses fast-before-effort (`grok-4.5-fast-high`); also accept effort-before-fast. - XCTAssertEqual(pricing.resolve(model: "grok-4.5-fast-high"), fast) - XCTAssertEqual(pricing.resolve(model: "grok-4.5-fast-medium"), fast) - XCTAssertEqual(pricing.resolve(model: "grok-4.5-fast-xhigh"), fast) - XCTAssertEqual(pricing.resolve(model: "grok-4.5-xhigh"), standard) - XCTAssertEqual(pricing.resolve(model: "grok-4-5-xhigh"), standard) - XCTAssertEqual(pricing.resolve(model: "grok-4-5-xhigh-fast"), fast) - XCTAssertEqual(pricing.resolve(model: "grok-4.5-medium-fast"), fast) - // Cursor usage export sometimes prefixes first-party Grok with `cursor-`. - XCTAssertEqual(pricing.resolve(model: "cursor-grok-4.5-high-fast"), fast) - XCTAssertEqual(pricing.resolve(model: "cursor-grok-4.5-fast-high"), fast) - XCTAssertEqual(pricing.resolve(model: "cursor-grok-4.5-high"), standard) - } - - /// Grok 4.6 (Cursor + SpaceXAI first-party): same published table rates as Grok 4.5, with - /// effort slugs and the `cursor-` CSV prefix collapsing to the same entries. - func testGrok46PricingAndAliases() throws { - let pricing = Self.pricing - let standard = try XCTUnwrap(pricing.resolve(model: "grok-4.6-high")) - XCTAssertEqual(standard.inputPerMillion, 2.0) - XCTAssertEqual(standard.cacheWritePerMillion, 2.0) - XCTAssertEqual(standard.cacheReadPerMillion, 0.5) - XCTAssertEqual(standard.outputPerMillion, 6.0) - XCTAssertEqual(pricing.resolve(model: "grok-4.6"), standard) - XCTAssertEqual(pricing.resolve(model: "grok-4.6-build"), standard) - XCTAssertEqual(pricing.resolve(model: "grok-4.6-low"), standard) + for version in ["4.5", "4.6"] { + let dashedVersion = version.replacingOccurrences(of: ".", with: "-") + let standard = try XCTUnwrap(pricing.resolve(model: "grok-\(version)-high")) + let fast = try XCTUnwrap(pricing.resolve(model: "grok-\(version)-fast")) + XCTAssertEqual( + [standard.inputPerMillion, standard.cacheWritePerMillion, standard.cacheReadPerMillion, standard.outputPerMillion], + [2, 2, 0.5, 6], + version + ) + XCTAssertEqual( + [fast.inputPerMillion, fast.cacheWritePerMillion, fast.cacheReadPerMillion, fast.outputPerMillion], + [4, 4, 1, 12], + version + ) - let fast = try XCTUnwrap(pricing.resolve(model: "grok-4.6-fast")) - XCTAssertEqual(fast.inputPerMillion, 4.0) - XCTAssertEqual(fast.cacheWritePerMillion, 4.0) - XCTAssertEqual(fast.cacheReadPerMillion, 1.0) - XCTAssertEqual(fast.outputPerMillion, 12.0) - XCTAssertEqual(pricing.resolve(model: "grok-4.6-fast-high"), fast) - XCTAssertEqual(pricing.resolve(model: "grok-4.6-high-fast"), fast) - XCTAssertEqual(pricing.resolve(model: "grok-4.6-xhigh"), standard) - XCTAssertEqual(pricing.resolve(model: "grok-4-6-xhigh"), standard) - XCTAssertEqual(pricing.resolve(model: "grok-4-6-xhigh-fast"), fast) - XCTAssertEqual(pricing.resolve(model: "cursor-grok-4.6-high-fast"), fast) - XCTAssertEqual(pricing.resolve(model: "cursor-grok-4.6-fast-high"), fast) - XCTAssertEqual(pricing.resolve(model: "cursor-grok-4.6-high"), standard) - XCTAssertEqual(pricing.supplement.canonicalName(for: "cursor-grok-4.6-high"), "grok-4.6") - XCTAssertEqual(pricing.supplement.canonicalName(for: "cursor-grok-4.6-high-fast"), "grok-4.6-fast") + for alias in [ + "grok-\(version)", "grok-\(version)-build", "grok-\(version)-low", + "grok-\(version)-xhigh", "grok-\(dashedVersion)-xhigh", "cursor-grok-\(version)-high" + ] { + XCTAssertEqual(pricing.resolve(model: alias), standard, alias) + } + for alias in [ + "grok-\(version)-fast-high", "grok-\(version)-fast-medium", "grok-\(version)-fast-xhigh", + "grok-\(version)-high-fast", "grok-\(version)-medium-fast", "grok-\(dashedVersion)-xhigh-fast", + "cursor-grok-\(version)-high-fast", "cursor-grok-\(version)-fast-high" + ] { + XCTAssertEqual(pricing.resolve(model: alias), fast, alias) + } + XCTAssertEqual(pricing.supplement.canonicalName(for: "cursor-grok-\(version)-high"), "grok-\(version)") + XCTAssertEqual(pricing.supplement.canonicalName(for: "cursor-grok-\(version)-high-fast"), "grok-\(version)-fast") + } } /// Kimi K2.7 Code: Cursor's published rates override messy public-catalog entries. @@ -386,13 +335,4 @@ final class PricingBundledResourceTests: XCTestCase { XCTAssertEqual(pricing.resolve(model: "kimi-k2p7"), kimi) XCTAssertEqual(pricing.resolve(model: "kimi-k2p7-code"), kimi) } - - func testCostSumsAllBucketsAndUnpricedIsNil() throws { - let pricing = Self.pricing - let entry = try XCTUnwrap(pricing.resolve(model: "composer-1")) - let tokens = TokenBreakdown(input: 1_000_000, cacheWrite5m: 1_000_000, cacheRead: 1_000_000, output: 1_000_000) - let expected = entry.inputPerMillion + entry.cacheWritePerMillion + entry.cacheReadPerMillion + entry.outputPerMillion - XCTAssertEqual(pricing.estimatedCostDollars(model: "composer-1", tokens: tokens)!, expected, accuracy: 1e-9) - XCTAssertNil(pricing.estimatedCostDollars(model: "nope", tokens: tokens)) - } } diff --git a/Tests/OpenUsageTests/ProviderEnablementStoreTests.swift b/Tests/OpenUsageTests/ProviderEnablementStoreTests.swift index 89c29eb2f..c738daea1 100644 --- a/Tests/OpenUsageTests/ProviderEnablementStoreTests.swift +++ b/Tests/OpenUsageTests/ProviderEnablementStoreTests.swift @@ -13,7 +13,7 @@ final class ProviderEnablementStoreTests: XCTestCase { XCTAssertTrue(store.isEnabled("a-provider-that-ships-next-year")) } - func testDisablingPersistsAcrossInstances() { + func testDisablingAndReenablingPersistAcrossInstances() { let defaults = makeDefaults("persist") let store = ProviderEnablementStore(defaults: defaults) @@ -26,21 +26,15 @@ final class ProviderEnablementStoreTests: XCTestCase { XCTAssertEqual(reloaded.disabledIDs, ["codex"]) XCTAssertFalse(reloaded.isEnabled("codex")) XCTAssertTrue(reloaded.isEnabled("claude")) - } - - func testReEnablingClearsDisabledStateAndPersists() { - let defaults = makeDefaults("re-enable") - let store = ProviderEnablementStore(defaults: defaults) - store.setEnabled(false, for: "grok") - store.setEnabled(true, for: "grok") + store.setEnabled(true, for: "codex") XCTAssertTrue(store.disabledIDs.isEmpty) - XCTAssertTrue(store.isEnabled("grok")) + XCTAssertTrue(store.isEnabled("codex")) - let reloaded = ProviderEnablementStore(defaults: defaults) - XCTAssertTrue(reloaded.disabledIDs.isEmpty) - XCTAssertTrue(reloaded.isEnabled("grok")) + let reenabled = ProviderEnablementStore(defaults: defaults) + XCTAssertTrue(reenabled.disabledIDs.isEmpty) + XCTAssertTrue(reenabled.isEnabled("codex")) } // MARK: - Early-refresh signal diff --git a/Tests/OpenUsageTests/ProviderLinksTests.swift b/Tests/OpenUsageTests/ProviderLinksTests.swift index 23342d50c..1331bf467 100644 --- a/Tests/OpenUsageTests/ProviderLinksTests.swift +++ b/Tests/OpenUsageTests/ProviderLinksTests.swift @@ -11,7 +11,6 @@ final class ProviderLinksTests: XCTestCase { func testNoLinksYieldsEmptyVisibleLinks() { XCTAssertTrue(provider([]).visibleLinks.isEmpty) - XCTAssertTrue(provider([.init(label: "", url: "")]).visibleLinks.isEmpty) } func testKeepsValidHttpsAndHttp() { @@ -19,36 +18,26 @@ final class ProviderLinksTests: XCTestCase { ProviderLink(label: "Status", url: "https://status.example.com/"), ProviderLink(label: "HTTP", url: "http://example.com/dashboard") ] - let visible = provider(links).visibleLinks - XCTAssertEqual(visible.count, 2) - XCTAssertEqual(visible[0].label, "Status") - XCTAssertEqual(visible[0].url, "https://status.example.com/") - XCTAssertEqual(visible[1].url, "http://example.com/dashboard") + XCTAssertEqual(provider(links).visibleLinks, links) } - func testDropsEmptyLabelOrUrl() { - let links = [ + func testRejectsEmptyOrWhitespaceOnlyFields() { + let invalidLinks = [ ProviderLink(label: "", url: "https://example.com/"), ProviderLink(label: "No URL", url: ""), - ProviderLink(label: "Both", url: "https://example.com/") - ] - XCTAssertEqual(provider(links).visibleLinks.map(\.label), ["Both"]) - } - - func testDropsWhitespaceOnlyLabelOrUrl() { - let links = [ ProviderLink(label: " ", url: "https://example.com/"), - ProviderLink(label: "Spaces", url: " "), - ProviderLink(label: "Kept", url: "https://example.com/") + ProviderLink(label: "Spaces", url: " ") ] - XCTAssertEqual(provider(links).visibleLinks.map(\.label), ["Kept"]) + for link in invalidLinks { + XCTAssertTrue(provider([link]).visibleLinks.isEmpty, "\(link)") + } } func testTrimsLabelAndUrl() { - let visible = provider([.init(label: " Status ", url: " https://status.example.com/ ")]).visibleLinks - XCTAssertEqual(visible.count, 1) - XCTAssertEqual(visible[0].label, "Status") - XCTAssertEqual(visible[0].url, "https://status.example.com/") + XCTAssertEqual( + provider([.init(label: " Status ", url: " https://status.example.com/ ")]).visibleLinks, + [ProviderLink(label: "Status", url: "https://status.example.com/")] + ) } func testRejectsNonHttpSchemes() { @@ -76,11 +65,10 @@ final class ProviderLinksTests: XCTestCase { @MainActor func testInstalledProvidersRespectQuickLinkCap() { let allowed = Set(["Status", "Dashboard", "API Keys", "Usage", "Activity", "Credits"]) - let providers: [ProviderRuntime] = [ - ClaudeProvider(), CodexProvider(), CursorProvider(), - AntigravityProvider(), CopilotProvider(), DevinProvider(), - GrokProvider(), OpenCodeProvider(), OpenRouterProvider(), ZAIProvider() - ] + let suiteName = "ProviderLinksTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let providers = ProviderCatalog.make(defaults: defaults) for runtime in providers { let links = runtime.provider.visibleLinks XCTAssertLessThanOrEqual( diff --git a/Tests/OpenUsageTests/ProviderMarksTests.swift b/Tests/OpenUsageTests/ProviderMarksTests.swift index 3f967f860..2b7be2980 100644 --- a/Tests/OpenUsageTests/ProviderMarksTests.swift +++ b/Tests/OpenUsageTests/ProviderMarksTests.swift @@ -3,23 +3,10 @@ import XCTest @MainActor final class ProviderMarksTests: XCTestCase { - func testGrokResolvesToVectorMarkNotBoltFallback() { - let mark = ProviderMarks.mark(for: "grok") - XCTAssertNotNil(mark, "Grok must load a real vector mark instead of the bolt.fill fallback") - XCTAssertFalse(mark?.path.isEmpty ?? true, "Grok mark must carry SVG path data") - } - - func testDevinResolvesToVectorMark() { - let mark = ProviderMarks.mark(for: "devin") - XCTAssertNotNil(mark) - XCTAssertFalse(mark?.path.isEmpty ?? true, "Devin mark must carry SVG path data") - } - - func testStandardProviderMarksLoad() { - for id in ["claude", "codex", "cursor"] { - let mark = ProviderMarks.mark(for: id) - XCTAssertNotNil(mark, "\(id) should load") - XCTAssertFalse(mark?.path.isEmpty ?? true, "\(id) mark must carry SVG path data") + func testProviderVectorMarksLoadWithoutFallbacks() throws { + for id in ["claude", "codex", "cursor", "devin", "grok"] { + let mark = try XCTUnwrap(ProviderMarks.mark(for: id), "\(id) should load a vector mark") + XCTAssertFalse(mark.path.isEmpty, "\(id) mark must carry SVG path data") } } } diff --git a/Tests/OpenUsageTests/ProviderSnapshotCacheTests.swift b/Tests/OpenUsageTests/ProviderSnapshotCacheTests.swift index e8ed029be..d95ee0ecc 100644 --- a/Tests/OpenUsageTests/ProviderSnapshotCacheTests.swift +++ b/Tests/OpenUsageTests/ProviderSnapshotCacheTests.swift @@ -35,22 +35,6 @@ final class ProviderSnapshotCacheTests: XCTestCase { .progress(label: "Session", used: 20, limit: 100, format: .percent)) } - func testWritesPersistForAFreshInstance() { - let (defaults, suite) = makeDefaults() - defer { defaults.removePersistentDomain(forName: suite) } - let now = Date() - ProviderSnapshotCache(userDefaults: defaults, storageKey: "k", ttl: 9_999, now: { now }) - .store(snapshot("alpha", used: 42, now: now)) - - // A fresh instance starts with an empty mirror, so the *display* read (`loadSnapshots`) proves the - // write reached disk — the mirror is a cache over persistence, not a replacement for it. (The - // freshness gate `snapshot(providerID:)` deliberately treats this disk-loaded value as stale; see - // `testRelaunchLoadedSnapshotIsStaleEvenWithinTTL`.) - let reloaded = ProviderSnapshotCache(userDefaults: defaults, storageKey: "k", ttl: 9_999, now: { now }) - XCTAssertEqual(reloaded.loadSnapshots(providerIDs: ["alpha"])["alpha"]?.lines.first, - .progress(label: "Session", used: 42, limit: 100, format: .percent)) - } - /// #697 core guarantee: a snapshot persisted by a *previous* session and reloaded on launch must not /// satisfy the refresh gate, even when its `refreshedAt` is still well within TTL — otherwise the app /// would wait out the previous session's remaining interval before refetching. It must still *display* @@ -65,26 +49,13 @@ final class ProviderSnapshotCacheTests: XCTestCase { // Session 2 (fresh instance = relaunch) reloads it from disk. let relaunched = ProviderSnapshotCache(userDefaults: defaults, storageKey: "k", ttl: 9_999, now: { now }) - // Display still paints the last-known value... - XCTAssertNotNil(relaunched.loadSnapshots(providerIDs: ["alpha"])["alpha"]) - // ...but the refresh gate treats it as stale, forcing a refresh on the first post-launch pass. + XCTAssertEqual( + relaunched.loadSnapshots(providerIDs: ["alpha"])["alpha"]?.lines.first, + .progress(label: "Session", used: 42, limit: 100, format: .percent) + ) XCTAssertNil(relaunched.snapshot(providerID: "alpha")) } - /// Acceptance criterion 2: a snapshot written *this* session still short-circuits a redundant refresh - /// within that session (no refresh storm) — the gate is "written this session AND within TTL", not - /// "written this session" alone. - func testSnapshotWrittenThisSessionStaysFreshWithinTTL() { - let (defaults, suite) = makeDefaults() - defer { defaults.removePersistentDomain(forName: suite) } - let now = Date() - let cache = ProviderSnapshotCache(userDefaults: defaults, storageKey: "k", ttl: 9_999, now: { now }) - - cache.store(snapshot("alpha", used: 42, now: now)) - XCTAssertEqual(cache.snapshot(providerID: "alpha")?.lines.first, - .progress(label: "Session", used: 42, limit: 100, format: .percent)) - } - /// A snapshot written this session still expires once it ages past TTL, so the periodic loop resumes /// refetching on the normal cadence (the session-write flag widens freshness on launch, it doesn't /// pin a snapshot fresh forever). diff --git a/Tests/OpenUsageTests/ReduceAnimationsSettingTests.swift b/Tests/OpenUsageTests/ReduceAnimationsSettingTests.swift index 88939a478..22ab704c7 100644 --- a/Tests/OpenUsageTests/ReduceAnimationsSettingTests.swift +++ b/Tests/OpenUsageTests/ReduceAnimationsSettingTests.swift @@ -4,16 +4,10 @@ import SwiftUI @MainActor final class ReduceAnimationsSettingTests: XCTestCase { - func testMotionStaysEnabledWithoutEitherPreference() { - XCTAssertFalse(ReduceAnimationsSetting.resolve(appPreference: false, systemReduceMotion: false)) - } - - func testAppPreferenceReducesAnimations() { - XCTAssertTrue(ReduceAnimationsSetting.resolve(appPreference: true, systemReduceMotion: false)) - } - - func testSystemPreferenceReducesAnimations() { - XCTAssertTrue(ReduceAnimationsSetting.resolve(appPreference: false, systemReduceMotion: true)) + func testEitherPreferenceReducesAnimations() { + for (app, system, expected) in [(false, false, false), (true, false, true), (false, true, true)] { + XCTAssertEqual(ReduceAnimationsSetting.resolve(appPreference: app, systemReduceMotion: system), expected) + } } func testPersistenceKeyAndFallbackStayStable() { @@ -21,22 +15,14 @@ final class ReduceAnimationsSettingTests: XCTestCase { XCTAssertFalse(ReduceAnimationsSetting.fallback) } - func testReducedAnimationsClearsAndLocksTheRootTransaction() { - var transaction = Transaction(animation: .linear(duration: 1)) - - Motion.applyReduction(to: &transaction, enabled: true) + func testRootTransactionOnlyDisablesAnimationsWhenReduced() { + for reduced in [false, true] { + var transaction = Transaction(animation: .linear(duration: 1)) + Motion.applyReduction(to: &transaction, enabled: reduced) - XCTAssertNil(transaction.animation) - XCTAssertTrue(transaction.disablesAnimations) - } - - func testNormalMotionLeavesTheRootTransactionUntouched() { - var transaction = Transaction(animation: .linear(duration: 1)) - - Motion.applyReduction(to: &transaction, enabled: false) - - XCTAssertNotNil(transaction.animation) - XCTAssertFalse(transaction.disablesAnimations) + XCTAssertEqual(transaction.animation == nil, reduced) + XCTAssertEqual(transaction.disablesAnimations, reduced) + } } func testReducedAnimationsNeverMountsScreenTransitionPager() { @@ -48,50 +34,21 @@ final class ReduceAnimationsSettingTests: XCTestCase { )) } - func testSettingsOverlayFollowsItsPagerSlotDuringASlide() { - XCTAssertEqual( - DashboardView.settingsOverlayOffset( - pages: [.dashboard, .settings], - slideOffset: 0, - pageWidth: 320 - ), - 320 - ) - XCTAssertEqual( - DashboardView.settingsOverlayOffset( - pages: [.dashboard, .settings], - slideOffset: -320, - pageWidth: 320 - ), - 0 - ) - XCTAssertEqual( - DashboardView.settingsOverlayOffset( - pages: [.settings], - slideOffset: 0, - pageWidth: 320 - ), - 0 - ) - } - - func testSettingsOverlayParksOffscreenWhenNotInThePager() { - XCTAssertEqual( - DashboardView.settingsOverlayOffset( - pages: [.dashboard], - slideOffset: 0, - pageWidth: 320 - ), - 640 - ) - XCTAssertEqual( - DashboardView.settingsOverlayOffset( - pages: [.dashboard, .customize], - slideOffset: -160, - pageWidth: 320 - ), - 640 - ) + func testSettingsOverlayFollowsItsPagerSlotOrParksOffscreen() { + let cases: [(pages: [PopoverScreen], offset: CGFloat, expected: CGFloat)] = [ + ([.dashboard, .settings], 0, 320), + ([.dashboard, .settings], -320, 0), + ([.settings], 0, 0), + ([.dashboard], 0, 640), + ([.dashboard, .customize], -160, 640) + ] + + for item in cases { + XCTAssertEqual( + DashboardView.settingsOverlayOffset(pages: item.pages, slideOffset: item.offset, pageWidth: 320), + item.expected + ) + } } func testSettingsChromeOnlyMountsWhileItsPageIsVisibleOrSliding() { diff --git a/Tests/OpenUsageTests/RefreshSettingTests.swift b/Tests/OpenUsageTests/RefreshSettingTests.swift index 3e1e0f60b..546a9d605 100644 --- a/Tests/OpenUsageTests/RefreshSettingTests.swift +++ b/Tests/OpenUsageTests/RefreshSettingTests.swift @@ -38,7 +38,7 @@ final class RefreshSettingTests: XCTestCase { } func testWithinSessionPassServedFromCacheUntilInterval() async { - let now = Date(timeIntervalSince1970: 1_800_000_000) + var now = Date(timeIntervalSince1970: 1_800_000_000) let suite = makeDefaults("within-session") // One cache instance shared between the seeding write and the store models a single running @@ -62,20 +62,10 @@ final class RefreshSettingTests: XCTestCase { XCTAssertEqual(runtime.refreshCount, 0) // fetched this session, within interval => no refetch XCTAssertNotNil(store.snapshots["test"]) - } - - func testCacheExpiresPastInterval() async { - let now = Date(timeIntervalSince1970: 1_800_000_000) - let suite = makeDefaults("restart-expired") - // A prior session left a snapshot 6 minutes ago — older than the 5-minute interval. - storeSnapshot(used: 20, age: 360, into: suite, now: now) - - let runtime = makeRuntime(used: 80) - let store = makeStore(runtime: runtime, suite: suite, now: now) + now = now.addingTimeInterval(61) await store.refreshAll() - - XCTAssertEqual(runtime.refreshCount, 1) // past interval => refetched + XCTAssertEqual(runtime.refreshCount, 1, "the same-session cache expires after the refresh interval") } // MARK: - Helpers diff --git a/Tests/OpenUsageTests/ResetDisplayTests.swift b/Tests/OpenUsageTests/ResetDisplayTests.swift index c8ce998fa..fa176f137 100644 --- a/Tests/OpenUsageTests/ResetDisplayTests.swift +++ b/Tests/OpenUsageTests/ResetDisplayTests.swift @@ -49,28 +49,23 @@ final class ResetDisplayTests: XCTestCase { XCTAssertEqual(data.resetTooltip()?.hasPrefix("Resets in "), true) // opposite = relative } - func testFreshSessionWindowShowsNotStartedForClaudeAndAntigravity() { + func testFreshSessionWindowShowsNotStartedAndSuppressesPacing() { let now = Date(timeIntervalSince1970: 1_800_000_000) let period: TimeInterval = 5 * 3600 - for id in ["claude.session", - "antigravity.geminiPro", "antigravity.claude"] { - var data = WidgetData(title: "Session", icon: .providerMark("codex"), kind: .percent, used: 0, limit: 100) - data.isSessionWindow = true // descriptor opt-in the session tiles now carry - data.periodDurationMs = Int(period * 1000) - // Half the window has elapsed on the clock, so pace would otherwise project — but usage is - // still zero, which is what "Not started" keys off (see `isFreshSessionWindow`). - data.resetsAt = now.addingTimeInterval(period / 2) - XCTAssertEqual(data.boundedTrailingText(now: now), "Not started", id) - XCTAssertFalse(data.hasResetLabel(now: now), id) - XCTAssertEqual(data.resetTooltip(now: now), WidgetData.freshSessionTooltip, id) - // The bar and its hover must not contradict "Not started": a calm level state, no pace - // projection and no tick — even with pacing forced on and the window well past minimumElapsed. - data.alwaysShowPacing = true - let state = data.meterState(now: now) - XCTAssertEqual(state, .level(.normal), id) - XCTAssertNil(state.tooltip, id) - XCTAssertNil(data.paceTick(for: state, now: now), id) - } + var data = WidgetData(title: "Session", icon: .providerMark("codex"), kind: .percent, used: 0, limit: 100) + data.isSessionWindow = true + data.periodDurationMs = Int(period * 1000) + data.resetsAt = now.addingTimeInterval(period / 2) + + XCTAssertEqual(data.boundedTrailingText(now: now), "Not started") + XCTAssertFalse(data.hasResetLabel(now: now)) + XCTAssertEqual(data.resetTooltip(now: now), WidgetData.freshSessionTooltip) + + data.alwaysShowPacing = true + let state = data.meterState(now: now) + XCTAssertEqual(state, .level(.normal)) + XCTAssertNil(state.tooltip) + XCTAssertNil(data.paceTick(for: state, now: now)) } @MainActor @@ -94,19 +89,15 @@ final class ResetDisplayTests: XCTestCase { XCTAssertEqual(suffixed.first?.sample.traySuffix, "resets") } - func testAntigravityWeeklyRowsNeverReadNotStarted() { - // Antigravity's weekly meters are calendar windows, not rolling sessions — like Claude, - // only the 5h rows get the "Not started" treatment (fix: merged pools + weekly limits). + func testNonSessionWindowNeverReadsNotStarted() { let now = Date(timeIntervalSince1970: 1_800_000_000) let period: TimeInterval = 7 * 24 * 3600 - for id in ["antigravity.geminiWeekly", "antigravity.claudeWeekly"] { - var data = WidgetData(title: "Weekly", icon: .providerMark("codex"), kind: .percent, used: 0, limit: 100) - data.periodDurationMs = Int(period * 1000) - data.resetsAt = now.addingTimeInterval(period / 2) - XCTAssertFalse(data.isFreshSessionWindow(now: now), id) - XCTAssertNotEqual(data.boundedTrailingText(now: now), "Not started", id) - XCTAssertEqual(data.boundedTrailingText(now: now)?.hasPrefix("Resets"), true, id) - } + var data = WidgetData(title: "Weekly", icon: .providerMark("codex"), kind: .percent, used: 0, limit: 100) + data.periodDurationMs = Int(period * 1000) + data.resetsAt = now.addingTimeInterval(period / 2) + + XCTAssertFalse(data.isFreshSessionWindow(now: now)) + XCTAssertEqual(data.boundedTrailingText(now: now)?.hasPrefix("Resets"), true) } func testExpiryTooltipSingleCreditFollowsTimeSetting() { @@ -200,29 +191,16 @@ final class ResetDisplayTests: XCTestCase { XCTAssertEqual(entries[1].countdown, "12d 18h") // countdown trails } - func testResetsPopoverPastDueEntryReadsSoonWithNoCountdown() { - // A past-due credit (still "available" until the next refresh drops it) can't print a useful - // wall-clock time or countdown, so it collapses to "Expiring soon" with no trailing countdown — - // matching Formatters.imminent. Its dot stays red. - let now = Date(timeIntervalSince1970: 1_800_000_000) - let entries = RateLimitResetsDetail.entries(from: [now.addingTimeInterval(-60)], now: now) - - XCTAssertEqual(entries.count, 1) - XCTAssertEqual(entries[0].time, "Expiring soon") - XCTAssertNil(entries[0].countdown) - XCTAssertEqual(entries[0].severity, .critical) - } - - func testResetsPopoverImminentFutureCreditCollapsesToSoon() { - // A credit ≤5 minutes out (but not yet past-due): relative mode already reads "soon", so the - // exact time must not print a wall-clock while the countdown vanishes — both collapse to - // "Expiring soon" with no countdown. + func testResetsPopoverPastDueAndImminentEntriesReadSoonWithNoCountdown() { let now = Date(timeIntervalSince1970: 1_800_000_000) - let entries = RateLimitResetsDetail.entries(from: [now.addingTimeInterval(180)], now: now) + for offset in [-60.0, 180.0] { + let entries = RateLimitResetsDetail.entries(from: [now.addingTimeInterval(offset)], now: now) - XCTAssertEqual(entries.count, 1) - XCTAssertEqual(entries[0].time, "Expiring soon") - XCTAssertNil(entries[0].countdown) + XCTAssertEqual(entries.count, 1) + XCTAssertEqual(entries[0].time, "Expiring soon") + XCTAssertNil(entries[0].countdown) + XCTAssertEqual(entries[0].severity, .critical) + } } func testResetsPopoverEmptyWhenNoCredits() { diff --git a/Tests/OpenUsageTests/SecretCodeMatcherTests.swift b/Tests/OpenUsageTests/SecretCodeMatcherTests.swift index 27a77fc57..4a0158422 100644 --- a/Tests/OpenUsageTests/SecretCodeMatcherTests.swift +++ b/Tests/OpenUsageTests/SecretCodeMatcherTests.swift @@ -12,31 +12,14 @@ final class SecretCodeMatcherTests: XCTestCase { XCTAssertTrue(matcher.accept(code.last!), "the final token completes the sequence") } - func testExtraLeadingKeysStillMatch() { - var matcher = SecretCodeMatcher() - // Two stray ups before a clean entry: the sliding window keeps only the last N, so it matches. - let stream: [SecretCodeKey] = [.up, .up] + code - var matched = false - for token in stream { matched = matcher.accept(token) } - XCTAssertTrue(matched) - } - - func testWrongKeyMidSequenceThenCleanEntryMatches() { - var matcher = SecretCodeMatcher() - _ = matcher.accept(.up) - _ = matcher.accept(.up) - _ = matcher.accept(.down) - _ = matcher.accept(.left) // wrong (expected .down) — run broken - var matched = false - for token in code { matched = matcher.accept(token) } - XCTAssertTrue(matched, "a clean entry after a fumble still matches") - } - - func testNoMatchForIncompleteSequence() { - var matcher = SecretCodeMatcher() - var matched = false - for token in code.dropLast() { matched = matcher.accept(token) || matched } - XCTAssertFalse(matched) + func testCleanEntryMatchesAfterStrayOrIncorrectPrefix() { + let prefixes: [[SecretCodeKey]] = [[.up, .up], [.up, .up, .down, .left]] + for prefix in prefixes { + var matcher = SecretCodeMatcher() + var matched = false + for token in prefix + code { matched = matcher.accept(token) } + XCTAssertTrue(matched) + } } func testResetClearsPartialProgress() { diff --git a/Tests/OpenUsageTests/SettingsMigratorTests.swift b/Tests/OpenUsageTests/SettingsMigratorTests.swift index 8e3fe57e7..fd4b8ac98 100644 --- a/Tests/OpenUsageTests/SettingsMigratorTests.swift +++ b/Tests/OpenUsageTests/SettingsMigratorTests.swift @@ -37,20 +37,19 @@ final class SettingsMigratorTests: XCTestCase { // MARK: - Cascading - /// The headline case: a big version jump runs every intermediate step in ascending order and stops - /// at current — a v7 install opening a v13 build. + /// A version jump runs only the pending intermediate steps in ascending order. func testCascadeRunsAllIntermediateStepsInOrder() { let (defaults, domain) = makeDefaults("Cascade") defer { defaults.removePersistentDomain(forName: domain) } - defaults.set(7, forKey: SettingsMigrator.schemaVersionKey) + defaults.set(1, forKey: SettingsMigrator.schemaVersionKey) let result = SettingsMigrator.migrate( defaults: defaults, domainName: domain, - current: 13, migrations: recording(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13) + current: 4, migrations: recording(1, 2, 3, 4) ) - XCTAssertEqual(result, 13) - XCTAssertEqual(ranVersions(defaults), [8, 9, 10, 11, 12, 13], "only steps above the stored version, in order") + XCTAssertEqual(result, 4) + XCTAssertEqual(ranVersions(defaults), [2, 3, 4], "only pending steps run, in order") } /// Migrations declared out of order are still applied by ascending version. @@ -67,34 +66,20 @@ final class SettingsMigratorTests: XCTestCase { XCTAssertEqual(ranVersions(defaults), [1, 2, 3]) } - /// Already at the current version: nothing runs. - func testSameVersionIsNoOp() { - let (defaults, domain) = makeDefaults("Same") - defer { defaults.removePersistentDomain(forName: domain) } - defaults.set(3, forKey: SettingsMigrator.schemaVersionKey) + func testCurrentAndNewerVersionsNeverReplayMigrations() { + for storedVersion in [3, 5] { + let (defaults, domain) = makeDefaults("NoReplay\(storedVersion)") + defer { defaults.removePersistentDomain(forName: domain) } + defaults.set(storedVersion, forKey: SettingsMigrator.schemaVersionKey) - let result = SettingsMigrator.migrate( - defaults: defaults, domainName: domain, current: 3, migrations: recording(1, 2, 3) - ) + let result = SettingsMigrator.migrate( + defaults: defaults, domainName: domain, current: 3, migrations: recording(1, 2, 3) + ) - XCTAssertEqual(result, 3) - XCTAssertNil(ranVersions(defaults)) - } - - /// A build older than the stored version (downgrade) leaves the recorded version untouched and runs - /// nothing — old migrations are never replayed backward. - func testDowngradeLeavesVersionUntouched() { - let (defaults, domain) = makeDefaults("Downgrade") - defer { defaults.removePersistentDomain(forName: domain) } - defaults.set(5, forKey: SettingsMigrator.schemaVersionKey) - - let result = SettingsMigrator.migrate( - defaults: defaults, domainName: domain, current: 3, migrations: recording(1, 2, 3) - ) - - XCTAssertEqual(result, 5) - XCTAssertEqual(defaults.integer(forKey: SettingsMigrator.schemaVersionKey), 5) - XCTAssertNil(ranVersions(defaults)) + XCTAssertEqual(result, storedVersion) + XCTAssertEqual(defaults.integer(forKey: SettingsMigrator.schemaVersionKey), storedVersion) + XCTAssertNil(ranVersions(defaults)) + } } /// A version bump with no data change for the top step still records reaching `current`, so the diff --git a/Tests/OpenUsageTests/SingleInstanceGuardTests.swift b/Tests/OpenUsageTests/SingleInstanceGuardTests.swift index abea61a58..16a5954eb 100644 --- a/Tests/OpenUsageTests/SingleInstanceGuardTests.swift +++ b/Tests/OpenUsageTests/SingleInstanceGuardTests.swift @@ -7,29 +7,22 @@ import XCTest /// thin glue over this pure function and aren't unit-testable (they need a second running process). @MainActor final class SingleInstanceGuardTests: XCTestCase { - func testSoloLaunchYieldsToNobody() { - // Only our own process is running — nothing to defer to. - XCTAssertNil(SingleInstanceGuard.instanceToYieldTo(myPID: 42, runningPIDs: [42])) - } - - func testNoRunningAppsYieldsToNobody() { - // Defensive: an empty workspace result must never make us yield. - XCTAssertNil(SingleInstanceGuard.instanceToYieldTo(myPID: 42, runningPIDs: [])) - } - - func testYieldsToALowerPIDInstance() { - // A copy with a lower PID (7) already owns the slot — we yield to it. - XCTAssertEqual(SingleInstanceGuard.instanceToYieldTo(myPID: 42, runningPIDs: [7, 42]), 7) - } - - func testYieldsToTheLowestWhenSeveralAreLower() { - // The survivor is the single lowest PID, not just any lower one. - XCTAssertEqual(SingleInstanceGuard.instanceToYieldTo(myPID: 42, runningPIDs: [20, 9, 42]), 9) - } + func testOnlyTheLowestRunningPIDOwnsTheInstance() { + let scenarios: [(name: String, running: [pid_t], expected: pid_t?)] = [ + ("solo launch", [42], nil), + ("empty workspace", [], nil), + ("lower PID owns the instance", [7, 42], 7), + ("lowest of several PIDs owns the instance", [20, 9, 42], 9), + ("higher PID yields to us", [42, 99], nil) + ] - func testSurvivesWhenWeAreTheLowestPID() { - // A higher-PID peer (99) yields to us, not the other way around — we keep running. - XCTAssertNil(SingleInstanceGuard.instanceToYieldTo(myPID: 42, runningPIDs: [42, 99])) + for scenario in scenarios { + XCTAssertEqual( + SingleInstanceGuard.instanceToYieldTo(myPID: 42, runningPIDs: scenario.running), + scenario.expected, + scenario.name + ) + } } /// The headline regression test for the reboot race (cubic P1 / Bugbot): two launches that both diff --git a/Tests/OpenUsageTests/SpendTileMapperTests.swift b/Tests/OpenUsageTests/SpendTileMapperTests.swift index 9380f7f84..a36f7ea10 100644 --- a/Tests/OpenUsageTests/SpendTileMapperTests.swift +++ b/Tests/OpenUsageTests/SpendTileMapperTests.swift @@ -11,11 +11,7 @@ import XCTest final class SpendTileMapperTests: XCTestCase { func testIdleRecentDaysLeftUnbacked() { // The source's last reported day is 3 days before today: today and yesterday are idle. - var lines: [MetricLine] = [] - SpendTileMapper.appendTokenUsage( - series([("2026-06-22", 5_000), ("2026-06-23", 7_000)]), - to: &lines, now: day(2026, 6, 26), estimated: false - ) + let lines = mappedLines(series([("2026-06-22", 5_000), ("2026-06-23", 7_000)])) XCTAssertNil(line(lines, "Today"), "an idle today is left unbacked → tile reads No data") XCTAssertNil(line(lines, "Yesterday"), "ditto yesterday — not a fabricated $0.00") @@ -26,11 +22,7 @@ final class SpendTileMapperTests: XCTestCase { // Used today and two days ago but not yesterday: a zero-token yesterday is "No data" too, not a // measured $0.00 — the branch between "absent" and "in-range zero" is gone. (Tokens-only rows // carry no cost — these series have costUSD nil — so a used day shows just its token count.) - var lines: [MetricLine] = [] - SpendTileMapper.appendTokenUsage( - series([("2026-06-24", 9_000), ("2026-06-26", 3_000)]), - to: &lines, now: day(2026, 6, 26), estimated: false - ) + let lines = mappedLines(series([("2026-06-24", 9_000), ("2026-06-26", 3_000)])) XCTAssertEqual(values(lines, "Today"), [MetricValue(number: 3_000, kind: .count, label: "tokens")]) XCTAssertNil(line(lines, "Yesterday"), "an idle in-range day is No data, not $0.00 · 0 tokens") @@ -39,21 +31,21 @@ final class SpendTileMapperTests: XCTestCase { func testEmptySeriesLeavesAllTilesUnbacked() { // The source ran but found nothing in the whole window (e.g. a brand-new user): every period is // idle, so nothing is appended and all three tiles read "No data". - var lines: [MetricLine] = [] - SpendTileMapper.appendTokenUsage( - DailyUsageSeries(daily: []), to: &lines, now: day(2026, 6, 26), estimated: false - ) - - XCTAssertTrue(lines.isEmpty, "an all-zero window appends no spend tiles") + let cases: [[DailyUsageEntry]] = [ + [], + [DailyUsageEntry(date: "2026-06-25", totalTokens: 0, costUSD: nil)] + ] + + for daily in cases { + XCTAssertTrue(mappedLines(daily).isEmpty, "an all-zero window appends no spend tiles") + } } func testUsedDayRendersItsValues() { // A day with real usage renders its token count (and cost, when the source prices it). - var lines: [MetricLine] = [] - SpendTileMapper.appendTokenUsage( - DailyUsageSeries(daily: [DailyUsageEntry(date: "2026-06-26", totalTokens: 12_000, costUSD: 1.50)]), - to: &lines, now: day(2026, 6, 26), estimated: true - ) + let lines = mappedLines([ + DailyUsageEntry(date: "2026-06-26", totalTokens: 12_000, costUSD: 1.50) + ]) XCTAssertEqual(values(lines, "Today"), [MetricValue(number: 1.50, kind: .dollars, estimated: true), @@ -61,48 +53,28 @@ final class SpendTileMapperTests: XCTestCase { } func testSingleModelPeriodStillGetsModelBreakdown() throws { - var lines: [MetricLine] = [] - SpendTileMapper.appendTokenUsage( - DailyUsageSeries(daily: [ - DailyUsageEntry(date: "2026-06-26", totalTokens: 300, costUSD: 3) - ]), - to: &lines, - now: day(2026, 6, 26), - estimated: true, - modelUsage: ModelUsageSeries(daily: [ - DailyModelUsageEntry(date: "2026-06-26", models: [ - ModelUsageEntry(model: "gpt-5.5", totalTokens: 300, costUSD: 3) - ]) - ]), - modelSourceNote: "From test logs" + let model = ModelUsageEntry(model: "gpt-5.5", totalTokens: 300, costUSD: 3) + let lines = mappedLines( + [DailyUsageEntry(date: "2026-06-26", totalTokens: 300, costUSD: 3)], + models: [DailyModelUsageEntry(date: "2026-06-26", models: [model])] ) let breakdown = try XCTUnwrap(modelBreakdown(lines, "Today")) XCTAssertEqual(breakdown.totalTokens, 300) XCTAssertEqual(breakdown.totalCostUSD, 3) - XCTAssertEqual(breakdown.models, [ - ModelUsageEntry(model: "gpt-5.5", totalTokens: 300, costUSD: 3) - ]) + XCTAssertEqual(breakdown.models, [model]) } func testModelBreakdownISODateMatchesLocalPeriodDay() throws { let now = day(2026, 6, 26) let localStart = Calendar.current.startOfDay(for: now) - var lines: [MetricLine] = [] - SpendTileMapper.appendTokenUsage( - DailyUsageSeries(daily: [ - DailyUsageEntry(date: dayKey(now), totalTokens: 300, costUSD: 3) - ]), - to: &lines, - now: now, - estimated: true, - modelUsage: ModelUsageSeries(daily: [ - DailyModelUsageEntry(date: isoString(localStart), models: [ - ModelUsageEntry(model: "gpt-5.5", totalTokens: 300, costUSD: 3) - ]) - ]), - modelSourceNote: "From test logs" + let lines = mappedLines( + [DailyUsageEntry(date: dayKey(now), totalTokens: 300, costUSD: 3)], + models: [DailyModelUsageEntry(date: isoString(localStart), models: [ + ModelUsageEntry(model: "gpt-5.5", totalTokens: 300, costUSD: 3) + ])], + now: now ) let breakdown = try XCTUnwrap(modelBreakdown(lines, "Today")) @@ -110,16 +82,12 @@ final class SpendTileMapperTests: XCTestCase { } func testModelBreakdownScopesTodayYesterdayAndLast30() throws { - var lines: [MetricLine] = [] - SpendTileMapper.appendTokenUsage( - DailyUsageSeries(daily: [ + let lines = mappedLines( + [ DailyUsageEntry(date: "2026-06-26", totalTokens: 300, costUSD: 3), DailyUsageEntry(date: "2026-06-25", totalTokens: 700, costUSD: 7) - ]), - to: &lines, - now: day(2026, 6, 26), - estimated: true, - modelUsage: ModelUsageSeries(daily: [ + ], + models: [ DailyModelUsageEntry(date: "2026-06-26", models: [ ModelUsageEntry(model: "alpha", totalTokens: 100, costUSD: 1), ModelUsageEntry(model: "beta", totalTokens: 200, costUSD: 2) @@ -128,8 +96,7 @@ final class SpendTileMapperTests: XCTestCase { ModelUsageEntry(model: "alpha", totalTokens: 300, costUSD: 3), ModelUsageEntry(model: "gamma", totalTokens: 400, costUSD: 4) ]) - ]), - modelSourceNote: "From test logs" + ] ) let today = try XCTUnwrap(modelBreakdown(lines, "Today")) @@ -145,30 +112,25 @@ final class SpendTileMapperTests: XCTestCase { XCTAssertEqual(last30.totalCostUSD, 10) XCTAssertEqual(last30.models.map(\.model), ["alpha", "gamma", "beta"]) XCTAssertEqual(last30.sourceNote, "From test logs") + XCTAssertEqual(values(lines, "Last 30 Days"), [ + MetricValue(number: 10, kind: .dollars, estimated: true), + MetricValue(number: 1000, kind: .count, label: "tokens") + ]) } func testModelBreakdownSortsFoldsOtherAndKeepsUnpricedNamed() throws { - var lines: [MetricLine] = [] - SpendTileMapper.appendTokenUsage( - DailyUsageSeries(daily: [ - DailyUsageEntry(date: "2026-06-26", totalTokens: 3_700, costUSD: 49) - ]), - to: &lines, - now: day(2026, 6, 26), - estimated: true, - modelUsage: ModelUsageSeries(daily: [ - DailyModelUsageEntry(date: "2026-06-26", models: [ - ModelUsageEntry(model: "alpha", totalTokens: 100, costUSD: 10), - ModelUsageEntry(model: "beta", totalTokens: 200, costUSD: 9), - ModelUsageEntry(model: "aardvark", totalTokens: 300, costUSD: 9), - ModelUsageEntry(model: "delta", totalTokens: 400, costUSD: 7), - ModelUsageEntry(model: "epsilon", totalTokens: 500, costUSD: 6), - ModelUsageEntry(model: "zeta", totalTokens: 600, costUSD: 5), - ModelUsageEntry(model: "eta", totalTokens: 700, costUSD: 3), - ModelUsageEntry(model: "mystery", totalTokens: 900, costUSD: nil) - ]) - ]), - modelSourceNote: "From test logs" + let lines = mappedLines( + [DailyUsageEntry(date: "2026-06-26", totalTokens: 3_700, costUSD: 49)], + models: [DailyModelUsageEntry(date: "2026-06-26", models: [ + ModelUsageEntry(model: "alpha", totalTokens: 100, costUSD: 10), + ModelUsageEntry(model: "beta", totalTokens: 200, costUSD: 9), + ModelUsageEntry(model: "aardvark", totalTokens: 300, costUSD: 9), + ModelUsageEntry(model: "delta", totalTokens: 400, costUSD: 7), + ModelUsageEntry(model: "epsilon", totalTokens: 500, costUSD: 6), + ModelUsageEntry(model: "zeta", totalTokens: 600, costUSD: 5), + ModelUsageEntry(model: "eta", totalTokens: 700, costUSD: 3), + ModelUsageEntry(model: "mystery", totalTokens: 900, costUSD: nil) + ])] ) // The unpriced "mystery" puts the whole list on token shares (the basis the panel's percent @@ -188,22 +150,13 @@ final class SpendTileMapperTests: XCTestCase { } func testModelBreakdownFoldsSubFivePercentModelsIntoOther() throws { - var lines: [MetricLine] = [] - SpendTileMapper.appendTokenUsage( - DailyUsageSeries(daily: [ - DailyUsageEntry(date: "2026-06-26", totalTokens: 1_000, costUSD: 100) - ]), - to: &lines, - now: day(2026, 6, 26), - estimated: true, - modelUsage: ModelUsageSeries(daily: [ - DailyModelUsageEntry(date: "2026-06-26", models: [ - ModelUsageEntry(model: "big", totalTokens: 700, costUSD: 90), - ModelUsageEntry(model: "mid", totalTokens: 200, costUSD: 6), - ModelUsageEntry(model: "tiny", totalTokens: 100, costUSD: 4) - ]) - ]), - modelSourceNote: "From test logs" + let lines = mappedLines( + [DailyUsageEntry(date: "2026-06-26", totalTokens: 1_000, costUSD: 100)], + models: [DailyModelUsageEntry(date: "2026-06-26", models: [ + ModelUsageEntry(model: "big", totalTokens: 700, costUSD: 90), + ModelUsageEntry(model: "mid", totalTokens: 200, costUSD: 6), + ModelUsageEntry(model: "tiny", totalTokens: 100, costUSD: 4) + ])] ) // All priced → cost shares: 90% / 6% / 4%. "tiny" is under the 5% floor and folds into Other @@ -216,22 +169,13 @@ final class SpendTileMapperTests: XCTestCase { } func testModelBreakdownFoldsUnattributedIntoOtherRegardlessOfSize() throws { - var lines: [MetricLine] = [] - SpendTileMapper.appendTokenUsage( - DailyUsageSeries(daily: [ - DailyUsageEntry(date: "2026-06-26", totalTokens: 1_000, costUSD: 6) - ]), - to: &lines, - now: day(2026, 6, 26), - estimated: true, - modelUsage: ModelUsageSeries(daily: [ - DailyModelUsageEntry(date: "2026-06-26", models: [ - ModelUsageEntry(model: "grok-build", totalTokens: 600, costUSD: 6), - // 40% of tokens — well above the 5% floor, still folds. - ModelUsageEntry(model: ModelUsageEntry.unattributedModelName, totalTokens: 400, costUSD: nil) - ]) - ]), - modelSourceNote: "From test logs" + let lines = mappedLines( + [DailyUsageEntry(date: "2026-06-26", totalTokens: 1_000, costUSD: 6)], + models: [DailyModelUsageEntry(date: "2026-06-26", models: [ + ModelUsageEntry(model: "grok-build", totalTokens: 600, costUSD: 6), + // 40% of tokens — well above the 5% floor, still folds. + ModelUsageEntry(model: ModelUsageEntry.unattributedModelName, totalTokens: 400, costUSD: nil) + ])] ) let breakdown = try XCTUnwrap(modelBreakdown(lines, "Today")) @@ -243,21 +187,12 @@ final class SpendTileMapperTests: XCTestCase { } func testModelBreakdownGroupsCaseInsensitivelyAndKeepsDominantSpelling() throws { - var lines: [MetricLine] = [] - SpendTileMapper.appendTokenUsage( - DailyUsageSeries(daily: [ - DailyUsageEntry(date: "2026-06-26", totalTokens: 400, costUSD: 4) - ]), - to: &lines, - now: day(2026, 6, 26), - estimated: true, - modelUsage: ModelUsageSeries(daily: [ - DailyModelUsageEntry(date: "2026-06-26", models: [ - ModelUsageEntry(model: "GLM-5.2", totalTokens: 100, costUSD: 1), - ModelUsageEntry(model: "glm-5.2", totalTokens: 300, costUSD: 3) - ]) - ]), - modelSourceNote: "From test logs" + let lines = mappedLines( + [DailyUsageEntry(date: "2026-06-26", totalTokens: 400, costUSD: 4)], + models: [DailyModelUsageEntry(date: "2026-06-26", models: [ + ModelUsageEntry(model: "GLM-5.2", totalTokens: 100, costUSD: 1), + ModelUsageEntry(model: "glm-5.2", totalTokens: 300, costUSD: 3) + ])] ) let breakdown = try XCTUnwrap(modelBreakdown(lines, "Today")) @@ -270,16 +205,12 @@ final class SpendTileMapperTests: XCTestCase { } func testModelBreakdownMergesVariantsAcrossDays() throws { - var lines: [MetricLine] = [] - SpendTileMapper.appendTokenUsage( - DailyUsageSeries(daily: [ + let lines = mappedLines( + [ DailyUsageEntry(date: "2026-06-26", totalTokens: 300, costUSD: 3), DailyUsageEntry(date: "2026-06-25", totalTokens: 400, costUSD: 4) - ]), - to: &lines, - now: day(2026, 6, 26), - estimated: true, - modelUsage: ModelUsageSeries(daily: [ + ], + models: [ DailyModelUsageEntry(date: "2026-06-26", models: [ ModelUsageEntry(model: "opus", totalTokens: 300, costUSD: 3, variants: [ ModelUsageVariant(model: "opus-thinking-max", totalTokens: 300, costUSD: 3) @@ -291,8 +222,7 @@ final class SpendTileMapperTests: XCTestCase { ModelUsageVariant(model: "opus-thinking-high", totalTokens: 300, costUSD: 3) ]) ]) - ]), - modelSourceNote: "From test logs" + ] ) let last30 = try XCTUnwrap(modelBreakdown(lines, "Last 30 Days")) @@ -307,8 +237,24 @@ final class SpendTileMapperTests: XCTestCase { // MARK: - Helpers - private func series(_ days: [(String, Int)]) -> DailyUsageSeries { - DailyUsageSeries(daily: days.map { DailyUsageEntry(date: $0.0, totalTokens: $0.1, costUSD: nil) }) + private func series(_ days: [(String, Int)]) -> [DailyUsageEntry] { + days.map { DailyUsageEntry(date: $0.0, totalTokens: $0.1, costUSD: nil) } + } + + private func mappedLines( + _ daily: [DailyUsageEntry], + models: [DailyModelUsageEntry] = [], + now: Date? = nil + ) -> [MetricLine] { + var lines: [MetricLine] = [] + SpendTileMapper.appendTokenUsage( + DailyUsageSeries(daily: daily), + to: &lines, + now: now ?? day(2026, 6, 26), + modelUsage: models.isEmpty ? nil : ModelUsageSeries(daily: models), + modelSourceNote: "From test logs" + ) + return lines } /// A fixed instant at midday in the current calendar, so `dayKey(from:)` and the hyphenated input diff --git a/Tests/OpenUsageTests/StaleWhileRevalidateTests.swift b/Tests/OpenUsageTests/StaleWhileRevalidateTests.swift index 16cbfc1dd..119b38f09 100644 --- a/Tests/OpenUsageTests/StaleWhileRevalidateTests.swift +++ b/Tests/OpenUsageTests/StaleWhileRevalidateTests.swift @@ -15,21 +15,12 @@ final class StaleWhileRevalidateTests: XCTestCase { let cache = ProviderSnapshotCache(userDefaults: defaults, storageKey: "snapshots", ttl: 600, now: { Date() }) // Persist a snapshot that is well past the TTL (a relaunch hours later). - cache.store(ProviderSnapshot( - providerID: provider.id, - displayName: provider.displayName, - lines: [.progress(label: "Alpha", used: 40, limit: 100, format: .percent)], - refreshedAt: Date(timeIntervalSinceNow: -7200) - )) + cache.store(snapshot(used: 40, refreshedAt: Date(timeIntervalSinceNow: -7200))) let runtime = CountingProviderRuntime( provider: provider, descriptors: [descriptor], - snapshot: ProviderSnapshot( - providerID: provider.id, - displayName: provider.displayName, - lines: [.progress(label: "Alpha", used: 55, limit: 100, format: .percent)] - ) + snapshot: snapshot(used: 55) ) let store = WidgetDataStore( registry: WidgetRegistry(providers: [provider], descriptors: [descriptor]), @@ -55,11 +46,7 @@ final class StaleWhileRevalidateTests: XCTestCase { let runtime = MutableProviderRuntime( provider: provider, descriptors: [descriptor], - snapshot: ProviderSnapshot( - providerID: provider.id, - displayName: provider.displayName, - lines: [.progress(label: "Alpha", used: 40, limit: 100, format: .percent)] - ) + snapshot: snapshot(used: 40) ) let store = WidgetDataStore( registry: WidgetRegistry(providers: [provider], descriptors: [descriptor]), @@ -80,11 +67,7 @@ final class StaleWhileRevalidateTests: XCTestCase { XCTAssertEqual(store.data(for: descriptor).used, 40) // A later successful refresh clears the error again. - runtime.snapshot = ProviderSnapshot( - providerID: provider.id, - displayName: provider.displayName, - lines: [.progress(label: "Alpha", used: 60, limit: 100, format: .percent)] - ) + runtime.snapshot = snapshot(used: 60) await store.refreshAll(force: true) XCTAssertNil(store.errorMessage(for: provider.id)) XCTAssertEqual(store.data(for: descriptor).used, 60) @@ -122,14 +105,7 @@ final class StaleWhileRevalidateTests: XCTestCase { ) ]) ) - var firstSnapshot = ProviderSnapshot( - providerID: provider.id, - displayName: provider.displayName, - plan: "Original", - lines: [.progress(label: "Alpha", used: 40, limit: 100, format: .percent)], - refreshedAt: fixedNow, - usageHistory: history - ) + var firstSnapshot = snapshot(used: 40, plan: "Original", refreshedAt: fixedNow, usageHistory: history) firstSnapshot = UsageHistorySnapshotRenderer.render( local: firstSnapshot, history: history, @@ -151,13 +127,7 @@ final class StaleWhileRevalidateTests: XCTestCase { ) await store.refreshAll(force: true) - runtime.snapshot = ProviderSnapshot( - providerID: provider.id, - displayName: provider.displayName, - plan: "Current", - lines: [.progress(label: "Alpha", used: 60, limit: 100, format: .percent)], - refreshedAt: fixedNow.addingTimeInterval(300) - ) + runtime.snapshot = snapshot(used: 60, plan: "Current", refreshedAt: fixedNow.addingTimeInterval(300)) await store.refreshAll(force: true) let refreshed = try XCTUnwrap(store.localSnapshots[provider.id]) @@ -180,20 +150,12 @@ final class StaleWhileRevalidateTests: XCTestCase { let cache = ProviderSnapshotCache(userDefaults: defaults, storageKey: "snapshots", ttl: 600, now: { Date() }) // A fresh (within-TTL) cached snapshot, also loaded into `snapshots` by the store's init. - cache.store(ProviderSnapshot( - providerID: provider.id, - displayName: provider.displayName, - lines: [.progress(label: "Alpha", used: 40, limit: 100, format: .percent)] - )) + cache.store(snapshot(used: 40)) let runtime = CountingProviderRuntime( provider: provider, descriptors: [descriptor], - snapshot: ProviderSnapshot( - providerID: provider.id, - displayName: provider.displayName, - lines: [.progress(label: "Alpha", used: 55, limit: 100, format: .percent)] - ) + snapshot: snapshot(used: 55) ) let store = WidgetDataStore( registry: WidgetRegistry(providers: [provider], descriptors: [descriptor]), @@ -253,13 +215,7 @@ final class StaleWhileRevalidateTests: XCTestCase { let runtime = BlockingProviderRuntime( provider: provider, descriptors: [descriptor], - snapshot: ProviderSnapshot( - providerID: provider.id, - displayName: provider.displayName, - plan: "Last good", - lines: [.progress(label: "Alpha", used: 40, limit: 100, format: .percent)], - usageHistory: history - ) + snapshot: snapshot(used: 40, plan: "Last good", usageHistory: history) ) let cache = ProviderSnapshotCache(userDefaults: defaults, storageKey: "snapshots") let store = WidgetDataStore( @@ -272,11 +228,8 @@ final class StaleWhileRevalidateTests: XCTestCase { var historyChangeCount = 0 store.onLocalHistoryChanged = { historyChangeCount += 1 } - runtime.snapshot = ProviderSnapshot( - providerID: provider.id, - displayName: provider.displayName, - plan: "Partial cancelled result", - lines: [.progress(label: "Alpha", used: 0, limit: 100, format: .percent)], + runtime.snapshot = snapshot( + used: 0, plan: "Partial cancelled result", usageHistory: ProviderUsageHistory(series: DailyUsageSeries(daily: [])) ) runtime.blockNextRefresh = true @@ -315,16 +268,11 @@ final class StaleWhileRevalidateTests: XCTestCase { let runtime = MutableProviderRuntime( provider: provider, descriptors: [descriptor], - snapshot: ProviderSnapshot( - providerID: provider.id, - displayName: provider.displayName, - plan: "With history", - lines: [.progress(label: "Alpha", used: 40, limit: 100, format: .percent)], - usageHistory: ProviderUsageHistory( - series: DailyUsageSeries(daily: [ - DailyUsageEntry(date: "2026-07-17", totalTokens: 400, costUSD: 4) - ]) - ) + snapshot: snapshot( + used: 40, plan: "With history", + usageHistory: ProviderUsageHistory(series: DailyUsageSeries(daily: [ + DailyUsageEntry(date: "2026-07-17", totalTokens: 400, costUSD: 4) + ])) ) ) let store = WidgetDataStore( @@ -335,11 +283,8 @@ final class StaleWhileRevalidateTests: XCTestCase { ) _ = await store.refresh(providerID: provider.id, force: true) - runtime.snapshot = ProviderSnapshot( - providerID: provider.id, - displayName: provider.displayName, - plan: "Completed empty scan", - lines: [.progress(label: "Alpha", used: 50, limit: 100, format: .percent)], + runtime.snapshot = snapshot( + used: 50, plan: "Completed empty scan", usageHistory: ProviderUsageHistory(series: DailyUsageSeries(daily: [])) ) _ = await store.refresh(providerID: provider.id, force: true) @@ -372,6 +317,22 @@ final class StaleWhileRevalidateTests: XCTestCase { ) } + private func snapshot( + used: Double, + plan: String? = nil, + refreshedAt: Date = Date(), + usageHistory: ProviderUsageHistory? = nil + ) -> ProviderSnapshot { + ProviderSnapshot( + providerID: Self.testProvider.id, + displayName: Self.testProvider.displayName, + plan: plan, + lines: [.progress(label: "Alpha", used: used, limit: 100, format: .percent)], + refreshedAt: refreshedAt, + usageHistory: usageHistory + ) + } + func testCorruptCacheBlobRecoversToEmptyInsteadOfDroppingSilently() { // A non-decodable blob under the cache key (post-upgrade schema drift, a half-written // write, a manual `defaults` edit) must recover to an empty cache rather than crash — and, diff --git a/Tests/OpenUsageTests/StalenessLabelTests.swift b/Tests/OpenUsageTests/StalenessLabelTests.swift index 9d275ca03..14fb2dd80 100644 --- a/Tests/OpenUsageTests/StalenessLabelTests.swift +++ b/Tests/OpenUsageTests/StalenessLabelTests.swift @@ -10,18 +10,12 @@ import XCTest final class StalenessLabelTests: XCTestCase { private let now = Date(timeIntervalSince1970: 1_800_000_000) - func testFreshSnapshotHasNoStalenessLabel() { - let store = makeStore() - store.snapshots["devin"] = snapshot(refreshedAt: now) - XCTAssertNil(store.stalenessHint(for: "devin")) - } - - func testSnapshotWithinThresholdHasNoStalenessLabel() { - // One refresh interval old is normal right before the next pass — must not flicker a hint on - // healthy providers, so the threshold sits above a single interval. - let store = makeStore() - store.snapshots["devin"] = snapshot(refreshedAt: now.addingTimeInterval(-RefreshSetting.interval)) - XCTAssertNil(store.stalenessHint(for: "devin")) + func testFreshSnapshotsAndFutureClockSkewHaveNoStalenessLabel() { + for offset in [0, -RefreshSetting.interval, -(WidgetDataStore.stalenessThreshold - 1), 3_600] { + let store = makeStore() + store.snapshots["devin"] = snapshot(refreshedAt: now.addingTimeInterval(offset)) + XCTAssertNil(store.stalenessHint(for: "devin"), "offset: \(offset)") + } } func testStaleSnapshotSurfacesOutdatedHint() { @@ -38,26 +32,12 @@ final class StalenessLabelTests: XCTestCase { XCTAssertNotNil(store.stalenessHint(for: "devin")) } - func testSnapshotJustBelowThresholdIsNotStale() { - // One second under the threshold must stay clean — locks the boundary against drift. - let store = makeStore() - store.snapshots["devin"] = snapshot(refreshedAt: now.addingTimeInterval(-(WidgetDataStore.stalenessThreshold - 1))) - XCTAssertNil(store.stalenessHint(for: "devin")) - } - func testVeryStaleSnapshotFormatsTooltipInDays() { let store = makeStore() store.snapshots["devin"] = snapshot(refreshedAt: now.addingTimeInterval(-3 * 24 * 60 * 60)) XCTAssertEqual(store.stalenessHint(for: "devin")?.tooltip, "Last updated 3d 0h ago") } - func testFutureRefreshedAtHasNoStalenessLabel() { - // Clock skew can stamp a snapshot in the future; a negative age must never render a hint. - let store = makeStore() - store.snapshots["devin"] = snapshot(refreshedAt: now.addingTimeInterval(60 * 60)) - XCTAssertNil(store.stalenessHint(for: "devin")) - } - func testMissingSnapshotHasNoStalenessLabel() { let store = makeStore() XCTAssertNil(store.stalenessHint(for: "devin")) diff --git a/Tests/OpenUsageTests/TelemetryRecorderTests.swift b/Tests/OpenUsageTests/TelemetryRecorderTests.swift index 0a19ab06a..2517541ef 100644 --- a/Tests/OpenUsageTests/TelemetryRecorderTests.swift +++ b/Tests/OpenUsageTests/TelemetryRecorderTests.swift @@ -11,10 +11,9 @@ final class TelemetryRecorderTests: XCTestCase { private final class FakeSink: TelemetrySink { var events: [(name: String, properties: [String: Any])] = [] var enabledCalls: [Bool] = [] - var flushCount = 0 func capture(_ event: String, _ properties: [String: Any]) { events.append((event, properties)) } func setOptionalAnalyticsEnabled(_ enabled: Bool) { enabledCalls.append(enabled) } - func flush() { flushCount += 1 } + func flush() {} func events(named name: String) -> [[String: Any]] { events.filter { $0.name == name }.map(\.properties) } @@ -70,26 +69,34 @@ final class TelemetryRecorderTests: XCTestCase { XCTAssertEqual(props["unexpected_failure_count"] as? Int, 1) } - func testTickEmitsDailyActiveOncePerDayWithConfigSnapshot() { - let sink = FakeSink() - let store = makeStore("daily-active") - var clock = day(25) - let recorder = TelemetryRecorder(sink: sink, store: store, snapshot: { self.snapshot }, now: { clock }) - - recorder.tick() - recorder.tick() // same day → must not emit twice - - let active = sink.events(named: "app_daily_active") - XCTAssertEqual(active.count, 1) - XCTAssertEqual(active[0]["install_id"] as? String, store.installID) - XCTAssertEqual(active[0]["enabled_providers"] as? [String], ["claude", "codex"]) - XCTAssertEqual(active[0]["pinned_metric_ids"] as? [String], ["claude.session"]) - XCTAssertEqual(active[0]["expanded_metric_ids"] as? [String], ["codex.weekly"]) - XCTAssertEqual(active[0]["menu_bar_style"] as? String, "text") - - clock = day(26) - recorder.tick() // new day → emit again - XCTAssertEqual(sink.events(named: "app_daily_active").count, 2) + func testDailyActiveEmitsOncePerDayRegardlessOfAnalyticsOptOut() { + for analyticsEnabled in [true, false] { + let sink = FakeSink() + let store = makeStore("daily-active-\(analyticsEnabled)") + var clock = day(25) + let recorder = TelemetryRecorder(sink: sink, store: store, snapshot: { self.snapshot }, now: { clock }) + + if !analyticsEnabled { + recorder.setEnabled(false) + XCTAssertEqual(sink.enabledCalls, [false]) + XCTAssertFalse(store.enabled, "optional-analytics opt-out must persist") + } + + recorder.tick() + recorder.tick() + + let active = sink.events(named: "app_daily_active") + XCTAssertEqual(active.count, 1) + XCTAssertEqual(active[0]["install_id"] as? String, store.installID) + XCTAssertEqual(active[0]["enabled_providers"] as? [String], ["claude", "codex"]) + XCTAssertEqual(active[0]["pinned_metric_ids"] as? [String], ["claude.session"]) + XCTAssertEqual(active[0]["expanded_metric_ids"] as? [String], ["codex.weekly"]) + XCTAssertEqual(active[0]["menu_bar_style"] as? String, "text") + + clock = day(26) + recorder.tick() + XCTAssertEqual(sink.events(named: "app_daily_active").count, 2) + } } func testTickFlushesStalePriorDayCounterEvenWithoutNewOutcomes() { @@ -110,29 +117,6 @@ final class TelemetryRecorderTests: XCTestCase { XCTAssertEqual(rollups[0]["success_count"] as? Int, 1) } - func testAnalyticsOffStillEmitsDailyActiveOncePerLocalDay() { - let sink = FakeSink() - let store = makeStore("analytics-off-daily") - var clock = day(25) - let recorder = TelemetryRecorder(sink: sink, store: store, snapshot: { self.snapshot }, now: { clock }) - - recorder.setEnabled(false) - XCTAssertEqual(sink.enabledCalls, [false]) - XCTAssertFalse(store.enabled, "optional-analytics opt-out must persist") - - recorder.tick() - recorder.tick() // same day → must not emit twice - - let active = sink.events(named: "app_daily_active") - XCTAssertEqual(active.count, 1, "daily ping must still fire while analytics are off") - XCTAssertEqual(active[0]["install_id"] as? String, store.installID) - XCTAssertEqual(active[0]["enabled_providers"] as? [String], ["claude", "codex"]) - - clock = day(26) - recorder.tick() - XCTAssertEqual(sink.events(named: "app_daily_active").count, 2) - } - func testAnalyticsOffDoesNotEmitProviderRefreshEvents() { let sink = FakeSink() let store = makeStore("analytics-off-optional") diff --git a/Tests/OpenUsageTests/UsageReaderTests.swift b/Tests/OpenUsageTests/UsageReaderTests.swift index cc9753595..58a9da0f5 100644 --- a/Tests/OpenUsageTests/UsageReaderTests.swift +++ b/Tests/OpenUsageTests/UsageReaderTests.swift @@ -66,13 +66,7 @@ final class UsageReaderTests: XCTestCase { XCTAssertEqual(provider.refreshCount, 1) XCTAssertNotNil((object["providers"] as? [String: Any])?["stub"]) XCTAssertEqual(cached["stub"]?.line(label: "Weekly"), .progress( - label: "Weekly", - used: 20, - limit: 100, - format: .percent, - resetsAt: nil, - periodDurationMs: nil, - colorHex: nil + label: "Weekly", used: 20, limit: 100, format: .percent )) } diff --git a/Tests/OpenUsageTests/UsageTrendTests.swift b/Tests/OpenUsageTests/UsageTrendTests.swift index d57c1a382..0b9aaedb3 100644 --- a/Tests/OpenUsageTests/UsageTrendTests.swift +++ b/Tests/OpenUsageTests/UsageTrendTests.swift @@ -79,10 +79,10 @@ final class UsageTrendTests: XCTestCase { } func testTrendDropsDaysOlderThanTheWindow() { - // 40 distinct days (May 1–31, then June 1–9) with today = 6/9. The window is the 31 days ending - // today (5/10 … 6/9), so May 1–9 fall outside it and are dropped. - var daily = (1...31).map { DailyUsageEntry(date: String(format: "2026-05-%02d", $0), totalTokens: $0 * 1000, costUSD: nil) } - daily += (1...9).map { DailyUsageEntry(date: String(format: "2026-06-%02d", $0), totalTokens: $0 * 1000, costUSD: nil) } + let daily = [ + DailyUsageEntry(date: "2026-05-09", totalTokens: 9_000, costUSD: nil), + DailyUsageEntry(date: "2026-05-10", totalTokens: 1_000, costUSD: nil) + ] var lines: [MetricLine] = [] SpendTileMapper.appendUsageTrend(DailyUsageSeries(daily: daily), to: &lines, now: date(2026, 6, 9), note: "n") @@ -91,6 +91,7 @@ final class UsageTrendTests: XCTestCase { XCTAssertEqual(points.count, 31) XCTAssertEqual(points.first?.label, dayLabel(2026, 5, 10), "days older than 30 back are outside the window") XCTAssertEqual(points.last?.label, dayLabel(2026, 6, 9), "window ends today") + XCTAssertEqual(points.map(\.value).reduce(0, +), 1_000, "out-of-window usage is excluded") } func testTrendAggregatesDuplicateDaysAndParsesCompactDates() { @@ -138,10 +139,6 @@ final class UsageTrendTests: XCTestCase { func testUsageTrendDescriptorIsNotPinnable() { let provider = Provider(id: "claude", displayName: "Claude", icon: .providerMark("claude")) let descriptor = WidgetDescriptor.usageTrend(provider: provider) - XCTAssertEqual(descriptor.id, "claude.trend") - XCTAssertFalse(descriptor.pinnable) - XCTAssertTrue(descriptor.sample.isChart) - let suite = makeDefaults("pinnable") let store = LayoutStore( registry: WidgetRegistry(providers: [provider], descriptors: [descriptor]), diff --git a/Tests/OpenUsageTests/WidgetDataStoreAccountCacheTests.swift b/Tests/OpenUsageTests/WidgetDataStoreAccountCacheTests.swift index cb4f3439a..6d945155e 100644 --- a/Tests/OpenUsageTests/WidgetDataStoreAccountCacheTests.swift +++ b/Tests/OpenUsageTests/WidgetDataStoreAccountCacheTests.swift @@ -45,21 +45,6 @@ final class WidgetDataStoreAccountCacheTests: XCTestCase { ) } - /// A matching stamp keeps the entry: same account across launches, cache paints as always. - func testMatchingStampKeepsCachedEntryAtLaunch() { - let defaults = makeUserDefaults("match") - let cache = ProviderSnapshotCache(userDefaults: defaults, storageKey: "snapshots", ttl: 600, now: { Date() }) - cache.store(snapshot("claude", used: 40), producedByIdentityKey: "acct-A") - - let store = makeStore( - providers: [provider("claude")], - cache: cache, - defaults: defaults, - identityKeys: ["claude": "acct-A"] - ) - XCTAssertNotNil(store.snapshots["claude"]) - } - /// A mismatched stamp drops ONLY that entry: the swapped card starts blank until its refresh, /// while the other family's card with a matching stamp keeps painting. func testMismatchedStampDropsOnlyThatEntry() { @@ -160,23 +145,6 @@ final class WidgetDataStoreAccountCacheTests: XCTestCase { XCTAssertEqual(honored, .cacheHit) } - /// The single predicate every read path shares (`hasStaleAccountStamp`): true only when an entry - /// exists, the current identity is known, and the stamp fails to name it. - func testHasStaleAccountStampSemantics() { - let defaults = makeUserDefaults("predicate") - let cache = ProviderSnapshotCache(userDefaults: defaults, storageKey: "snapshots", ttl: 600, now: { Date() }) - - XCTAssertFalse(cache.hasStaleAccountStamp(providerID: "claude", currentIdentityKey: "acct-A"), "no entry, nothing to distrust") - - cache.store(snapshot("claude", used: 40), producedByIdentityKey: "acct-A") - XCTAssertFalse(cache.hasStaleAccountStamp(providerID: "claude", currentIdentityKey: "acct-A")) - XCTAssertFalse(cache.hasStaleAccountStamp(providerID: "claude", currentIdentityKey: nil), "unresolved identity can't prove staleness") - XCTAssertTrue(cache.hasStaleAccountStamp(providerID: "claude", currentIdentityKey: "acct-B")) - - cache.store(snapshot("claude", used: 41)) - XCTAssertTrue(cache.hasStaleAccountStamp(providerID: "claude", currentIdentityKey: "acct-A"), "an unstamped entry is unattributable") - } - /// A refresh writes the card's launch-resolved identity as the stamp, and a nil identity CLEARS /// any prior stamp — leaving the old account's stamp would falsely bless the new snapshot. func testStoreStampsAndClearsProducerIdentity() { diff --git a/Tests/OpenUsageTests/WidgetDataStoreNotificationTests.swift b/Tests/OpenUsageTests/WidgetDataStoreNotificationTests.swift index 7dbab68d4..12ea27751 100644 --- a/Tests/OpenUsageTests/WidgetDataStoreNotificationTests.swift +++ b/Tests/OpenUsageTests/WidgetDataStoreNotificationTests.swift @@ -100,20 +100,30 @@ final class WidgetDataStoreNotificationTests: XCTestCase { settings.closeToRunningOut = true } + private func evaluate( + _ store: WidgetDataStore, + runtime: MutableRuntime, + used: Double? = nil, + resetsAt: Date? = nil + ) async { + if let used { + runtime.snapshot = snapshot(used: used, resetsAt: resetsAt) + } + await store.refreshAll(force: true) + await store.evaluateNotifications(now: base) + } + func testHealthyToCloseFiresOnceThroughTheStore() async { let settings = NotificationSettingsStore(defaults: makeUserDefaults("h2c-settings")) allOn(settings) let recorder = Recorder() // 80% used at ~90% elapsed → projected ~89% → healthy. let (store, runtime, _) = makeStore(used: 80, settings: settings, recorder: recorder, defaultsName: "h2c") - await store.refreshAll(force: true) - await store.evaluateNotifications(now: base) + await evaluate(store, runtime: runtime) XCTAssertTrue(recorder.posts.isEmpty, "healthy should not fire") // Usage rises to 87% → projected ~96.7% → close. - runtime.snapshot = snapshot(used: 87) - await store.refreshAll(force: true) - await store.evaluateNotifications(now: base) + await evaluate(store, runtime: runtime, used: 87) XCTAssertEqual(recorder.posts.count, 1) XCTAssertEqual(recorder.posts.first?.0, "test.healthyToClose") XCTAssertEqual(recorder.posts.first?.1, "Cutting It Close") @@ -128,14 +138,11 @@ final class WidgetDataStoreNotificationTests: XCTestCase { allOn(settings) let recorder = Recorder() let (store, runtime, _) = makeStore(used: 87, settings: settings, recorder: recorder, defaultsName: "c2r") - await store.refreshAll(force: true) - await store.evaluateNotifications(now: base) // close → primes (first real obs), no fire + await evaluate(store, runtime: runtime) XCTAssertTrue(recorder.posts.isEmpty, "first launch primes the baseline without firing") // Usage rises to 95% → projected ~105% → red. - runtime.snapshot = snapshot(used: 95) - await store.refreshAll(force: true) - await store.evaluateNotifications(now: base) + await evaluate(store, runtime: runtime, used: 95) XCTAssertTrue(recorder.posts.contains { $0.0 == "test.closeToRunningOut" }) XCTAssertTrue(recorder.posts.contains { $0.3 == "Projected to finish before the limit resets." }) XCTAssertTrue(recorder.posts.contains { $0.2 == "Test Session" }) @@ -146,17 +153,12 @@ final class WidgetDataStoreNotificationTests: XCTestCase { allOn(settings) let recorder = Recorder() let (store, runtime, _) = makeStore(used: 80, settings: settings, recorder: recorder, defaultsName: "jitter") - await store.refreshAll(force: true) - await store.evaluateNotifications(now: base) // healthy -> primes, no fire + await evaluate(store, runtime: runtime) - runtime.snapshot = snapshot(used: 95) - await store.refreshAll(force: true) - await store.evaluateNotifications(now: base) // -> red, fires once + await evaluate(store, runtime: runtime, used: 95) XCTAssertEqual(recorder.posts.filter { $0.0 == "test.closeToRunningOut" }.count, 1) - runtime.snapshot = snapshot(used: 95, resetsAt: resetsAt.addingTimeInterval(0.09)) - await store.refreshAll(force: true) - await store.evaluateNotifications(now: base) // same red state, reset jitter only + await evaluate(store, runtime: runtime, used: 95, resetsAt: resetsAt.addingTimeInterval(0.09)) XCTAssertEqual(recorder.posts.filter { $0.0 == "test.closeToRunningOut" }.count, 1) } @@ -168,11 +170,8 @@ final class WidgetDataStoreNotificationTests: XCTestCase { settings.closeToRunningOut = false let recorder = Recorder() let (store, runtime, _) = makeStore(used: 80, settings: settings, recorder: recorder, defaultsName: "all-off") - await store.refreshAll(force: true) - await store.evaluateNotifications(now: base) - runtime.snapshot = snapshot(used: 95) - await store.refreshAll(force: true) - await store.evaluateNotifications(now: base) + await evaluate(store, runtime: runtime) + await evaluate(store, runtime: runtime, used: 95) XCTAssertTrue(recorder.posts.isEmpty) } @@ -182,16 +181,11 @@ final class WidgetDataStoreNotificationTests: XCTestCase { settings.healthyToClose = false // turn off "Cutting It Close" only let recorder = Recorder() let (store, runtime, _) = makeStore(used: 80, settings: settings, recorder: recorder, defaultsName: "per-trigger") - await store.refreshAll(force: true) - await store.evaluateNotifications(now: base) - runtime.snapshot = snapshot(used: 87) - await store.refreshAll(force: true) - await store.evaluateNotifications(now: base) + await evaluate(store, runtime: runtime) + await evaluate(store, runtime: runtime, used: 87) XCTAssertFalse(recorder.posts.contains { $0.0 == "test.healthyToClose" }) // The critical trigger is still on: pushing to red fires it. - runtime.snapshot = snapshot(used: 95) - await store.refreshAll(force: true) - await store.evaluateNotifications(now: base) + await evaluate(store, runtime: runtime, used: 95) XCTAssertTrue(recorder.posts.contains { $0.0 == "test.closeToRunningOut" }) } @@ -203,12 +197,9 @@ final class WidgetDataStoreNotificationTests: XCTestCase { // Prime from healthy, then worsen to red so a milestone fires before the disable. let (store, runtime, _) = makeStore(used: 80, settings: settings, recorder: recorder, defaultsName: "disable", isEnabled: { _ in enabled.value }) - await store.refreshAll(force: true) - await store.evaluateNotifications(now: base) // healthy → primes, no fire + await evaluate(store, runtime: runtime) XCTAssertEqual(recorder.posts.count, 0) - runtime.snapshot = snapshot(used: 95) // → red, fires - await store.refreshAll(force: true) - await store.evaluateNotifications(now: base) + await evaluate(store, runtime: runtime, used: 95) let firstCount = recorder.posts.count XCTAssertGreaterThan(firstCount, 0) @@ -229,11 +220,8 @@ final class WidgetDataStoreNotificationTests: XCTestCase { let recorder = Recorder() let (store, runtime, _) = makeStore(used: 80, settings: settings, recorder: recorder, defaultsName: "used-mode") store.meterStyle = .used - await store.refreshAll(force: true) - await store.evaluateNotifications(now: base) // healthy → primes, no fire - runtime.snapshot = snapshot(used: 95) // → under 10% remaining - await store.refreshAll(force: true) - await store.evaluateNotifications(now: base) + await evaluate(store, runtime: runtime) + await evaluate(store, runtime: runtime, used: 95) XCTAssertTrue( recorder.posts.contains { $0.0 == "test.underTenPercent" }, "Almost Out must fire on <10% remaining even when the meter displays 'used'" @@ -249,14 +237,9 @@ final class WidgetDataStoreNotificationTests: XCTestCase { let recorder = Recorder() let (store, runtime, _) = makeStore(used: 80, settings: settings, recorder: recorder, defaultsName: "retry", delivered: { false }) - await store.refreshAll(force: true) - await store.evaluateNotifications(now: base) // healthy → primes, no fire - runtime.snapshot = snapshot(used: 87) // → close - await store.refreshAll(force: true) - await store.evaluateNotifications(now: base) // attempt 1 (delivery "fails") - // Still close on the next tick — the un-delivered milestone re-fires instead of being deduped. - await store.refreshAll(force: true) - await store.evaluateNotifications(now: base) // attempt 2 (re-tried) + await evaluate(store, runtime: runtime) + await evaluate(store, runtime: runtime, used: 87) + await evaluate(store, runtime: runtime) XCTAssertEqual(recorder.posts.count, 2, "failed delivery should retry on the next tick") } } diff --git a/Tests/OpenUsageTests/WidgetDataStorePlanTests.swift b/Tests/OpenUsageTests/WidgetDataStorePlanTests.swift index 34ffd85b4..4fbafee9d 100644 --- a/Tests/OpenUsageTests/WidgetDataStorePlanTests.swift +++ b/Tests/OpenUsageTests/WidgetDataStorePlanTests.swift @@ -5,68 +5,31 @@ import XCTest /// snapshot, then mirrors that snapshot's `plan`. @MainActor final class WidgetDataStorePlanTests: XCTestCase { - func testPlanIsNilBeforeRefreshThenMirrorsSnapshot() async { + func testPlanMirrorsSnapshotAndHandlesMissingValues() async { let provider = Provider(id: "claude", displayName: "Claude", icon: .providerMark("claude")) - let descriptor = WidgetDescriptor( - id: "claude.session", - providerID: "claude", - metricLabel: "Session", - sample: WidgetData(title: "Session", icon: provider.icon, kind: .percent, used: 10, limit: 100) - ) - let runtime = TestProviderRuntime( - provider: provider, - descriptors: [descriptor], - snapshot: ProviderSnapshot( - providerID: "claude", - displayName: "Claude", - plan: "Max 20x", - lines: [.progress(label: "Session", used: 10, limit: 100, format: .percent)] + let cases: [(name: String, plan: String?)] = [("plan", "Max 20x"), ("no-plan", nil)] + + for item in cases { + let runtime = TestProviderRuntime( + provider: provider, + descriptors: [], + snapshot: ProviderSnapshot( + providerID: provider.id, displayName: provider.displayName, plan: item.plan, lines: [] + ) ) - ) - let defaults = makeDefaults("plan") - let store = WidgetDataStore( - registry: WidgetRegistry(providers: [provider], descriptors: [descriptor]), - providers: [runtime], - cache: ProviderSnapshotCache(userDefaults: defaults, storageKey: "snapshots", ttl: 600, now: { Date() }), - defaults: defaults - ) - - XCTAssertNil(store.plan(for: "claude")) - - await store.refreshAll() - - XCTAssertEqual(store.plan(for: "claude"), "Max 20x") - XCTAssertNil(store.plan(for: "unknown")) - } - - func testPlanIsNilWhenSnapshotHasNoPlan() async { - let provider = Provider(id: "codex", displayName: "Codex", icon: .providerMark("codex")) - let descriptor = WidgetDescriptor( - id: "codex.session", - providerID: "codex", - metricLabel: "Session", - sample: WidgetData(title: "Session", icon: provider.icon, kind: .percent, used: 50, limit: 100) - ) - let runtime = TestProviderRuntime( - provider: provider, - descriptors: [descriptor], - snapshot: ProviderSnapshot( - providerID: "codex", - displayName: "Codex", - lines: [.progress(label: "Session", used: 50, limit: 100, format: .percent)] + let defaults = makeDefaults(item.name) + let store = WidgetDataStore( + registry: WidgetRegistry(providers: [provider], descriptors: []), + providers: [runtime], + cache: ProviderSnapshotCache(userDefaults: defaults, storageKey: "snapshots", ttl: 600, now: { Date() }), + defaults: defaults ) - ) - let defaults = makeDefaults("no-plan") - let store = WidgetDataStore( - registry: WidgetRegistry(providers: [provider], descriptors: [descriptor]), - providers: [runtime], - cache: ProviderSnapshotCache(userDefaults: defaults, storageKey: "snapshots", ttl: 600, now: { Date() }), - defaults: defaults - ) - - await store.refreshAll() - XCTAssertNil(store.plan(for: "codex")) + XCTAssertNil(store.plan(for: provider.id)) + await store.refreshAll() + XCTAssertEqual(store.plan(for: provider.id), item.plan) + XCTAssertNil(store.plan(for: "unknown")) + } } private func makeDefaults(_ name: String) -> UserDefaults { diff --git a/Tests/OpenUsageTests/WidgetDataStoreTests.swift b/Tests/OpenUsageTests/WidgetDataStoreTests.swift index 8af3f8873..50dd39a90 100644 --- a/Tests/OpenUsageTests/WidgetDataStoreTests.swift +++ b/Tests/OpenUsageTests/WidgetDataStoreTests.swift @@ -115,99 +115,39 @@ final class WidgetDataStoreTests: XCTestCase { } func testRemainingProgressWithoutResetUsesPeriodDurationLabel() { - let session = WidgetData( - title: "Session", - icon: .providerMark("claude"), - kind: .percent, - used: 0, - limit: 100, - displayMode: .remaining, - resetsAt: nil, - periodDurationMs: ClaudeUsageMapper.sessionPeriodMs - ) - XCTAssertEqual(session.boundedSubtitle, "Resets in 5h") - - let weekly = WidgetData( - title: "Weekly", - icon: .providerMark("claude"), - kind: .percent, - used: 0, - limit: 100, - displayMode: .remaining, - resetsAt: nil, - periodDurationMs: ClaudeUsageMapper.weeklyPeriodMs - ) - XCTAssertEqual(weekly.boundedSubtitle, "Resets in 7d 0h") - } - - func testDollarLimitSubtitleIsNotAReset() { - // A dollar limit subtitle is not a reset countdown; it renders as plain "$ limit" text. - let onDemand = WidgetData( - title: "On-demand", icon: .providerMark("cursor"), - kind: .dollars, used: 0, limit: 100, limitNoun: "limit" - ) - XCTAssertEqual(onDemand.boundedSubtitle, "$100 limit") + for (period, expected) in [ + (ClaudeUsageMapper.sessionPeriodMs, "Resets in 5h"), + (ClaudeUsageMapper.weeklyPeriodMs, "Resets in 7d 0h") + ] { + let data = WidgetData( + title: "Usage", icon: .providerMark("claude"), kind: .percent, + used: 0, limit: 100, displayMode: .remaining, periodDurationMs: period + ) + XCTAssertEqual(data.boundedSubtitle, expected) + } } func testDonutFractionMatchesRoundedHeadline() { - // 0.39% used reads "0%", so the ring must be empty (no sliver), not 0.0039. - let nearlyZero = WidgetData( - title: "Total usage", - icon: .providerMark("cursor"), - kind: .percent, - used: 0.3915, - limit: 100 - ) - XCTAssertEqual(nearlyZero.valueText, "0%") - XCTAssertEqual(nearlyZero.fraction, 0, accuracy: 0.0001) - - // 0.6% rounds up to "1%", so the ring should match that 1%. - let roundsUp = WidgetData( - title: "Total usage", - icon: .providerMark("cursor"), - kind: .percent, - used: 0.6, - limit: 100 - ) - XCTAssertEqual(roundsUp.valueText, "1%") - XCTAssertEqual(roundsUp.fraction, 0.01, accuracy: 0.0001) - - // 99.6% used reads "100%", so the ring should be full. - let nearlyFull = WidgetData( - title: "Total usage", - icon: .providerMark("cursor"), - kind: .percent, - used: 99.6, - limit: 100 - ) - XCTAssertEqual(nearlyFull.valueText, "100%") - XCTAssertEqual(nearlyFull.fraction, 1, accuracy: 0.0001) - } - - func testOnDemandDollarLimitAppendsLimitNoun() { - let onDemand = WidgetData( - title: "On-Demand", - icon: .providerMark("cursor"), - kind: .dollars, - used: 0, - limit: 100, - limitNoun: "limit" - ) - XCTAssertEqual(onDemand.boundedSubtitle, "$100 limit") + for (used, headline, fraction) in [ + (0.3915, "0%", 0.0), (0.6, "1%", 0.01), (99.6, "100%", 1.0) + ] { + let data = WidgetData( + title: "Total usage", icon: .providerMark("cursor"), kind: .percent, + used: used, limit: 100 + ) + XCTAssertEqual(data.valueText, headline) + XCTAssertEqual(data.fraction, fraction, accuracy: 0.0001) + } } - func testCreditsDollarLimitAppendsLimitNoun() { - // Matches the original OpenUsage, which renders every bounded dollar metric's subtitle as - // "$X limit" — never "total". - let credits = WidgetData( - title: "Credits", - icon: .providerMark("cursor"), - kind: .dollars, - used: 0, - limit: 20, - limitNoun: "limit" - ) - XCTAssertEqual(credits.boundedSubtitle, "$20 limit") + func testBoundedDollarSubtitlesAppendLimitNoun() { + for (title, limit, expected) in [("On-Demand", 100.0, "$100 limit"), ("Credits", 20.0, "$20 limit")] { + let data = WidgetData( + title: title, icon: .providerMark("cursor"), kind: .dollars, + used: 0, limit: limit, limitNoun: "limit" + ) + XCTAssertEqual(data.boundedSubtitle, expected) + } } func testRequestsShowsBillingResetInsteadOfSuffix() { diff --git a/Tests/OpenUsageTests/WidgetMeterStyleTests.swift b/Tests/OpenUsageTests/WidgetMeterStyleTests.swift index c80b54efe..f605af976 100644 --- a/Tests/OpenUsageTests/WidgetMeterStyleTests.swift +++ b/Tests/OpenUsageTests/WidgetMeterStyleTests.swift @@ -6,87 +6,32 @@ import XCTest /// tiles untouched, and persists across launches. @MainActor final class WidgetMeterStyleTests: XCTestCase { - func testMeterStyleFlipsBoundedPercentTile() async { - let (store, descriptor) = await makeRefreshedStore( - format: .percent, - used: 80, - limit: 100, - suite: "percent" - ) - - XCTAssertEqual(store.meterStyle, .remaining) // empty suite default - let remaining = store.data(for: descriptor) - XCTAssertEqual(remaining.valueText, "20%") - XCTAssertEqual(remaining.boundedHeadline, "20% left") - XCTAssertNil(remaining.boundedSubtitle) - XCTAssertEqual(remaining.fraction, 0.20, accuracy: 0.0001) - - store.meterStyle = .used - let used = store.data(for: descriptor) - XCTAssertEqual(used.valueText, "80%") - XCTAssertEqual(used.boundedHeadline, "80% used") - XCTAssertNil(used.boundedSubtitle) - XCTAssertEqual(used.fraction, 0.80, accuracy: 0.0001) - } - - func testMeterStyleFlipsBoundedDollarsTile() async { - let (store, descriptor) = await makeRefreshedStore( - format: .dollars, - used: 80, - limit: 100, - suite: "dollars" - ) - - let remaining = store.data(for: descriptor) - XCTAssertEqual(remaining.valueText, "$20.00") - XCTAssertEqual(remaining.boundedHeadline, "$20.00 left") - XCTAssertEqual(remaining.boundedSubtitle, "$100 limit") - XCTAssertEqual(remaining.fraction, 0.20, accuracy: 0.0001) - - store.meterStyle = .used - let used = store.data(for: descriptor) - XCTAssertEqual(used.valueText, "$80.00") - XCTAssertEqual(used.boundedHeadline, "$80.00 used") - XCTAssertEqual(used.boundedSubtitle, "$100 limit") - XCTAssertEqual(used.fraction, 0.80, accuracy: 0.0001) - } - - func testMeterStyleFlipsBoundedCountTile() async { - let (store, descriptor) = await makeRefreshedStore( - format: .count(suffix: "credits"), - used: 320, - limit: 1000, - suite: "count" - ) - - let remaining = store.data(for: descriptor) - XCTAssertEqual(remaining.valueText, "680") - XCTAssertEqual(remaining.boundedHeadline, "680 left") - XCTAssertEqual(remaining.boundedSubtitle, "credits") - XCTAssertEqual(remaining.fraction, 0.68, accuracy: 0.0001) - - store.meterStyle = .used - let used = store.data(for: descriptor) - XCTAssertEqual(used.valueText, "320") - XCTAssertEqual(used.boundedHeadline, "320 used") - XCTAssertEqual(used.boundedSubtitle, "credits") - XCTAssertEqual(used.fraction, 0.32, accuracy: 0.0001) - } - - func testBoundedHeadlineWordFlipsSymmetricallyWithMeterStyle() async { - // The same tile must carry the mode word in BOTH modes (regression: "Used" mode had dropped it). - let (store, descriptor) = await makeRefreshedStore( - format: .percent, - used: 80, - limit: 100, - suite: "symmetry" - ) - - store.meterStyle = .remaining - XCTAssertEqual(store.data(for: descriptor).boundedHeadline, "20% left") + func testMeterStyleFlipsEveryBoundedFormat() async { + let cases: [(name: String, format: ProgressFormat, used: Double, limit: Double, + remaining: String, consumed: String, subtitle: String?)] = [ + ("percent", .percent, 80, 100, "20%", "80%", nil), + ("dollars", .dollars, 80, 100, "$20.00", "$80.00", "$100 limit"), + ("count", .count(suffix: "credits"), 320, 1_000, "680", "320", "credits") + ] + + for item in cases { + let (store, descriptor) = await makeRefreshedStore( + format: item.format, used: item.used, limit: item.limit, suite: item.name + ) - store.meterStyle = .used - XCTAssertEqual(store.data(for: descriptor).boundedHeadline, "80% used") + let remaining = store.data(for: descriptor) + XCTAssertEqual(remaining.valueText, item.remaining, item.name) + XCTAssertEqual(remaining.boundedHeadline, "\(item.remaining) left", item.name) + XCTAssertEqual(remaining.boundedSubtitle, item.subtitle, item.name) + XCTAssertEqual(remaining.fraction, 1 - item.used / item.limit, accuracy: 0.0001, item.name) + + store.meterStyle = .used + let used = store.data(for: descriptor) + XCTAssertEqual(used.valueText, item.consumed, item.name) + XCTAssertEqual(used.boundedHeadline, "\(item.consumed) used", item.name) + XCTAssertEqual(used.boundedSubtitle, item.subtitle, item.name) + XCTAssertEqual(used.fraction, item.used / item.limit, accuracy: 0.0001, item.name) + } } func testGlobalModeOverridesDescriptorSampleDisplayMode() async { diff --git a/Tests/OpenUsageTests/WidgetNoDataTests.swift b/Tests/OpenUsageTests/WidgetNoDataTests.swift index b5b9de17b..9db436127 100644 --- a/Tests/OpenUsageTests/WidgetNoDataTests.swift +++ b/Tests/OpenUsageTests/WidgetNoDataTests.swift @@ -6,34 +6,20 @@ import XCTest /// and never leak its placeholder sample numbers into the menu bar. @MainActor final class WidgetNoDataTests: XCTestCase { - func testDataForFlagsMissingLineAsNoData() async { - let (store, present, missing) = await makeRefreshedStore(suite: "missing-line") - - XCTAssertTrue(store.data(for: present).hasData) - XCTAssertFalse(store.data(for: missing).hasData) - } - - func testNoDataHeadlineAndTrailingCopy() async { - let (store, present, missing) = await makeRefreshedStore(suite: "copy") + func testMissingLineShowsNoDataAcrossDashboardAndMenuBar() async { + let (store, present, missing) = await makeRefreshedStore() let blank = store.data(for: missing) XCTAssertFalse(blank.hasData) XCTAssertEqual(blank.headline, "—") XCTAssertEqual(blank.boundedTrailingText(), "No data") + XCTAssertEqual(blank.valueText, WidgetData.noDataHeadline) let real = store.data(for: present) XCTAssertTrue(real.hasData) XCTAssertNotEqual(real.headline, "—") XCTAssertNotEqual(real.boundedTrailingText(), "No data") - } - - func testValueTextHidesPlaceholderWhenNoData() async { - // The menu bar reads `valueText`; a missing line must never leak the descriptor's placeholder - // template numbers there, so `valueText` reports the no-data marker just like the dashboard row. - let (store, present, missing) = await makeRefreshedStore(suite: "valuetext") - - XCTAssertEqual(store.data(for: missing).valueText, WidgetData.noDataHeadline) - XCTAssertNotEqual(store.data(for: present).valueText, WidgetData.noDataHeadline) + XCTAssertNotEqual(real.valueText, WidgetData.noDataHeadline) } // Menu-bar ordering / no-data-skip / fallback are exercised on the real tray path @@ -41,9 +27,7 @@ final class WidgetNoDataTests: XCTestCase { // MARK: - Helpers - private func makeRefreshedStore( - suite: String - ) async -> (WidgetDataStore, WidgetDescriptor, WidgetDescriptor) { + private func makeRefreshedStore() async -> (WidgetDataStore, WidgetDescriptor, WidgetDescriptor) { let provider = Provider(id: "test", displayName: "Test", icon: .providerMark("cursor")) let present = boundedPercent(provider, id: "test.present", metric: "Present", sampleUsed: 40) // Deliberately fake sample numbers we must never show once the account lacks this metric. @@ -57,11 +41,11 @@ final class WidgetNoDataTests: XCTestCase { lines: [.progress(label: "Present", used: 40, limit: 100, format: .percent)] ) ) - let defaults = makeUserDefaults(suite) + let defaults = makeUserDefaults("missing-line") let store = WidgetDataStore( registry: WidgetRegistry(providers: [provider], descriptors: [present, missing]), providers: [runtime], - cache: makeCache(defaults), + cache: ProviderSnapshotCache(userDefaults: defaults, storageKey: "snapshots", ttl: 600, now: { Date() }), defaults: defaults ) await store.refreshAll() @@ -88,10 +72,6 @@ final class WidgetNoDataTests: XCTestCase { ) } - private func makeCache(_ defaults: UserDefaults) -> ProviderSnapshotCache { - ProviderSnapshotCache(userDefaults: defaults, storageKey: "snapshots", ttl: 600, now: { Date() }) - } - private func makeUserDefaults(_ name: String) -> UserDefaults { let suiteName = "OpenUsageTests.NoData.\(name).\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName)! diff --git a/Tests/OpenUsageTests/WidgetPercentClampTests.swift b/Tests/OpenUsageTests/WidgetPercentClampTests.swift index 476f339ce..c75d6275e 100644 --- a/Tests/OpenUsageTests/WidgetPercentClampTests.swift +++ b/Tests/OpenUsageTests/WidgetPercentClampTests.swift @@ -7,45 +7,29 @@ import XCTest /// construction choke point (`WidgetDataStore.resolve`), with a defensive clamp in `MetricFormatter`. @MainActor final class WidgetPercentClampTests: XCTestCase { - func testNegativePercentSampleNeverRendersNegative() async { - let (store, descriptor) = await makePercentStore(used: -5, suite: "negative") + func testOutOfRangePercentSamplesClampAcrossEveryRenderingPath() async { + for (suite, raw, clamped) in [("negative", -5.0, 0.0), ("over", 130.0, 100.0)] { + let (store, descriptor) = await makePercentStore(used: raw, suite: suite) + let usedText = "\(Int(clamped))%" + let remainingText = "\(100 - Int(clamped))%" - // Default (remaining) mode: clamped used = 0 reads as a clean "0% used" / "100% left" meter. - store.meterStyle = .remaining - let remaining = store.data(for: descriptor) - XCTAssertEqual(remaining.used, 0) // sanitized at construction - XCTAssertEqual(remaining.valueText, "100%") - XCTAssertEqual(remaining.boundedHeadline, "100% left") - XCTAssertEqual(remaining.menuBarValue, "100%") - // The flip tooltip was the path that leaked "-5% used" even in the default mode. - XCTAssertEqual(remaining.meterStyleTooltip, "0% used") + for (mode, value, word, opposite) in [ + (WidgetDisplayMode.remaining, remainingText, "left", "\(usedText) used"), + (.used, usedText, "used", "\(remainingText) left") + ] { + store.meterStyle = mode + let data = store.data(for: descriptor) + XCTAssertEqual(data.used, clamped, suite) + XCTAssertEqual(data.valueText, value, suite) + XCTAssertEqual(data.boundedHeadline, "\(value) \(word)", suite) + XCTAssertEqual(data.menuBarValue, value, suite) + XCTAssertEqual(data.meterStyleTooltip, opposite, suite) + } - // Used mode: the headline itself was the visible "-5% used" bug. - store.meterStyle = .used - let used = store.data(for: descriptor) - XCTAssertEqual(used.valueText, "0%") - XCTAssertEqual(used.boundedHeadline, "0% used") - XCTAssertEqual(used.menuBarValue, "0%") - XCTAssertEqual(used.meterStyleTooltip, "100% left") - } - - func testOverHundredPercentSampleNeverRendersOverHundred() async { - let (store, descriptor) = await makePercentStore(used: 130, suite: "over") - - store.meterStyle = .used - let used = store.data(for: descriptor) - XCTAssertEqual(used.used, 100) // sanitized at construction - XCTAssertEqual(used.valueText, "100%") - XCTAssertEqual(used.boundedHeadline, "100% used") - XCTAssertEqual(used.menuBarValue, "100%") - // Overage still reads as spent — it's conveyed by the meter state, not an out-of-range number. - XCTAssertEqual(used.meterState(), .spent) - - store.meterStyle = .remaining - let remaining = store.data(for: descriptor) - XCTAssertEqual(remaining.valueText, "0%") - XCTAssertEqual(remaining.boundedHeadline, "0% left") - XCTAssertEqual(remaining.menuBarValue, "0%") + if clamped == 100 { + XCTAssertEqual(store.data(for: descriptor).meterState(), .spent) + } + } } // MARK: - Helper diff --git a/Tests/OpenUsageTests/ZAIProviderTests.swift b/Tests/OpenUsageTests/ZAIProviderTests.swift index 92b99779f..736e84512 100644 --- a/Tests/OpenUsageTests/ZAIProviderTests.swift +++ b/Tests/OpenUsageTests/ZAIProviderTests.swift @@ -1,79 +1,16 @@ import XCTest @testable import OpenUsage -// MARK: - Sample payloads -/// Mirrors the undocumented Z.ai internal-API shapes the legacy Tauri plugin relied on, captured in -/// `docs/providers/zai.md`. These endpoints are not in Z.ai's public API reference but are stable in -/// practice (used by Z.ai's own subscription UI). - private let quotaBothLimitsJSON = #""" -{ - "code": 200, - "data": { - "limits": [ - { - "type": "TOKENS_LIMIT", - "unit": 3, - "number": 5, - "usage": 800000000, - "currentValue": 127694464, - "remaining": 672305536, - "percentage": 15, - "nextResetTime": 1770648402389 - }, - { - "type": "TOKENS_LIMIT", - "unit": 6, - "number": 1, - "percentage": 40, - "nextResetTime": 1771300000000 - }, - { - "type": "TIME_LIMIT", - "unit": 5, - "number": 1, - "usage": 4000, - "currentValue": 1828, - "remaining": 2172, - "percentage": 45, - "usageDetails": [ - { "modelCode": "search-prime", "usage": 1433 }, - { "modelCode": "web-reader", "usage": 462 }, - { "modelCode": "zread", "usage": 0 } - ] - } - ] - }, - "success": true -} +{"data":{"limits":[ + {"type":"TOKENS_LIMIT","unit":3,"number":5,"percentage":15,"nextResetTime":1770648402389}, + {"type":"TOKENS_LIMIT","unit":6,"number":1,"percentage":40,"nextResetTime":1771300000000}, + {"type":"TIME_LIMIT","unit":5,"number":1,"usage":4000,"currentValue":1828} +]},"success":true} """# -private let quotaSessionOnlyJSON = #""" -{ - "code": 200, - "data": { - "limits": [ - { "type": "TOKENS_LIMIT", "unit": 3, "number": 5, "usage": 800000000, "currentValue": 0, "percentage": 0 } - ] - }, - "success": true -} -"""# - -private let subscriptionJSON = #""" -{ - "code": 200, - "data": [ - { - "id": "169359", - "productName": "GLM Coding Max", - "status": "VALID", - "nextRenewTime": "2026-03-12" - } - ], - "success": true -} -"""# +private let quotaSessionOnlyJSON = #"{"data":{"limits":[{"type":"TOKENS_LIMIT","unit":3,"number":5,"percentage":0}]}}"# +private let subscriptionJSON = #"{"data":[{"productName":"GLM Coding Max"}]}"# private func data(_ json: String) -> Data { Data(json.utf8) @@ -82,67 +19,29 @@ private func data(_ json: String) -> Data { // MARK: - ZAIAuthStoreTests final class ZAIAuthStoreTests: XCTestCase { - func testPrefersConfigFileOverEnvironment() { - // Config file wins so editing it to rotate the key isn't shadowed by a stale env value. - let store = ZAIAuthStore( - files: FakeFiles([ZAIAuthStore.configPaths[0]: #"{"apiKey":"zai-file"}"#]), - environment: FakeEnvironment(["ZAI_API_KEY": "zai-env"]) - ) - - let auth = store.loadAPIKey() - - XCTAssertEqual(auth?.apiKey, "zai-file") - } - - func testFallsBackToEnvironmentWhenNoConfigFile() { - let store = ZAIAuthStore( - files: FakeFiles(), - environment: FakeEnvironment(["ZAI_API_KEY": "zai-env"]) - ) - - let auth = store.loadAPIKey() - - XCTAssertEqual(auth?.apiKey, "zai-env") - } - - func testAcceptsLegacyGLMEnvName() { - // GLM_API_KEY is the older Zhipu name some users still export. - let store = ZAIAuthStore( - files: FakeFiles(), - environment: FakeEnvironment(["GLM_API_KEY": "glm-env"]) - ) - - XCTAssertEqual(store.loadAPIKey()?.apiKey, "glm-env") - } - - func testZAIKeyNameBeatsGLMKeyName() { - // ZAI_API_KEY is primary; GLM_API_KEY only the fallback. - let store = ZAIAuthStore( - files: FakeFiles(), - environment: FakeEnvironment(["ZAI_API_KEY": "zai", "GLM_API_KEY": "glm"]) - ) - - XCTAssertEqual(store.loadAPIKey()?.apiKey, "zai") - } - - func testReadsKeyFromJSONConfigFile() { - let store = ZAIAuthStore( - files: FakeFiles([ZAIAuthStore.configPaths[0]: #"{ "api_key": "zai-json" }"#]), - environment: FakeEnvironment() - ) - - let auth = store.loadAPIKey() + func testEnvironmentKeysSupportLegacyFallbackAndPrimaryPrecedence() { + let cases: [(environment: [String: String], expected: String)] = [ + (["ZAI_API_KEY": "zai-env"], "zai-env"), + (["GLM_API_KEY": "glm-env"], "glm-env"), + (["ZAI_API_KEY": "zai", "GLM_API_KEY": "glm"], "zai") + ] - XCTAssertEqual(auth?.apiKey, "zai-json") + for entry in cases { + let store = ZAIAuthStore(files: FakeFiles(), environment: FakeEnvironment(entry.environment)) + XCTAssertEqual(store.loadAPIKey()?.apiKey, entry.expected) + } } - func testReadsPlainTextKeyFile() { - let store = ZAIAuthStore( - files: FakeFiles([ZAIAuthStore.configPaths[1]: " zai-plain\n"]), - environment: FakeEnvironment() - ) + func testReadsJSONAndTrimmedPlainTextConfigFiles() { + let cases = [ + (ZAIAuthStore.configPaths[0], #"{"api_key":"zai-json"}"#, "zai-json"), + (ZAIAuthStore.configPaths[1], " zai-plain\n", "zai-plain") + ] - XCTAssertEqual(store.loadAPIKey()?.apiKey, "zai-plain") + for (path, content, expected) in cases { + let store = ZAIAuthStore(files: FakeFiles([path: content]), environment: FakeEnvironment()) + XCTAssertEqual(store.loadAPIKey()?.apiKey, expected, path) + } } func testReturnsNilWhenNoKeyAnywhere() { @@ -200,27 +99,22 @@ final class ZAIAuthStoreTests: XCTestCase { XCTAssertEqual(store.currentAPIKey(), "zai-file") } - func testDeleteAPIKeyFallsBackToEnvironment() throws { - let files = FakeFiles([ZAIAuthStore.configPaths[0]: #"{"apiKey":"zai-file"}"#]) - let store = ZAIAuthStore(files: files, environment: FakeEnvironment(["ZAI_API_KEY": "zai-env"])) - - XCTAssertEqual(store.keyStatus(), .overrideActive) - try store.deleteAPIKey() - - XCTAssertNil(files.files[ZAIAuthStore.configPaths[0]]) - XCTAssertEqual(store.keyStatus(), .fromEnvironment) - XCTAssertEqual(store.loadAPIKey()?.apiKey, "zai-env") - } - - func testDeleteAPIKeyBecomesNotSetWhenNoEnvKey() throws { - let files = FakeFiles([ZAIAuthStore.configPaths[0]: #"{"apiKey":"zai-file"}"#]) - let store = ZAIAuthStore(files: files, environment: FakeEnvironment()) + func testDeleteClearsEveryConfigPathAndPreservesEnvironmentFallback() throws { + for environment in [[:], ["ZAI_API_KEY": "zai-env"]] as [[String: String]] { + let files = FakeFiles([ + ZAIAuthStore.configPaths[0]: #"{"apiKey":"zai-primary"}"#, + ZAIAuthStore.configPaths[1]: "zai-alt" + ]) + let store = ZAIAuthStore(files: files, environment: FakeEnvironment(environment)) - try store.deleteAPIKey() + try store.deleteAPIKey() - XCTAssertNil(files.files[ZAIAuthStore.configPaths[0]]) - XCTAssertEqual(store.keyStatus(), .notSet) - XCTAssertNil(store.loadAPIKey()) + for path in ZAIAuthStore.configPaths { + XCTAssertNil(files.files[path], path) + } + XCTAssertEqual(store.loadAPIKey()?.apiKey, environment["ZAI_API_KEY"]) + XCTAssertEqual(store.keyStatus(), environment.isEmpty ? .notSet : .fromEnvironment) + } } func testDeleteAPIKeyIsNoOpWhenFileMissing() throws { @@ -229,21 +123,6 @@ final class ZAIAuthStoreTests: XCTestCase { XCTAssertEqual(store.keyStatus(), .notSet) } - func testDeleteAPIKeyClearsAllConfigPaths() throws { - // A key in the alternate config path must also be cleared, or it resurfaces after the primary - // file is deleted and the Settings "clear" appears not to work. - let files = FakeFiles([ - ZAIAuthStore.configPaths[0]: #"{"apiKey":"zai-primary"}"#, - ZAIAuthStore.configPaths[1]: "zai-alt" - ]) - let store = ZAIAuthStore(files: files, environment: FakeEnvironment()) - - try store.deleteAPIKey() - - XCTAssertNil(files.files[ZAIAuthStore.configPaths[0]]) - XCTAssertNil(files.files[ZAIAuthStore.configPaths[1]]) - XCTAssertEqual(store.keyStatus(), .notSet) - } } // MARK: - ZAIUsageMapperTests @@ -307,10 +186,6 @@ final class ZAIUsageMapperTests: XCTestCase { XCTAssertNil(progress(mapped.lines, "Web Searches")) } - func testPlanNameFromSubscription() { - XCTAssertEqual(ZAIUsageMapper.planName(from: data(subscriptionJSON)), "GLM Coding Max") - } - func testPlanNameNilWhenNoData() { XCTAssertNil(ZAIUsageMapper.planName(from: data(#"{"data":[]}"#))) } @@ -422,33 +297,23 @@ final class ZAIProviderTests: XCTestCase { XCTAssertEqual(snapshot.errorCategory, .notLoggedIn) } - func testRefreshOnAuthFailureReportsInvalidKey() async { - let provider = ZAIProvider( - authStore: makeAuthStore(key: "zai-bad"), - usageClient: ZAIUsageClient(http: RoutingHTTPClient { _ in - HTTPResponse(statusCode: 401, headers: [:], body: Data("{}".utf8)) - }) - ) - - let snapshot = await provider.refresh() - - XCTAssertEqual(snapshot.errorCategory, .authInvalid) - } + func testRefreshClassifiesAuthenticationAndServerFailures() async { + let cases: [(status: Int, expected: ErrorCategory)] = [(401, .authInvalid), (500, .http5xx)] - func testRefreshOnNon2xxReportsRequestFailed() async { - let provider = ZAIProvider( - authStore: makeAuthStore(key: "zai-test"), - usageClient: ZAIUsageClient(http: RoutingHTTPClient { request in - if request.url == ZAIUsageClient.quotaURL { - return HTTPResponse(statusCode: 500, headers: [:], body: Data("{}".utf8)) - } - return jsonResponse(subscriptionJSON) - }) - ) + for entry in cases { + let provider = ZAIProvider( + authStore: makeAuthStore(key: "zai-test"), + usageClient: ZAIUsageClient(http: RoutingHTTPClient { request in + request.url == ZAIUsageClient.quotaURL + ? HTTPResponse(statusCode: entry.status, headers: [:], body: Data("{}".utf8)) + : jsonResponse(subscriptionJSON) + }) + ) - let snapshot = await provider.refresh() + let snapshot = await provider.refresh() - XCTAssertEqual(snapshot.errorCategory, .http5xx) + XCTAssertEqual(snapshot.errorCategory, entry.expected) + } } func testRefreshOnTransportErrorReportsNetwork() async { @@ -509,8 +374,6 @@ final class ZAIProviderTests: XCTestCase { let provider = ZAIProvider() XCTAssertEqual(provider.provider.id, "zai") XCTAssertEqual(provider.provider.displayName, "Z.ai") - // Console + API Keys quick links render in the card's expanded area. - XCTAssertEqual(provider.provider.visibleLinks.count, 2) } private func makeAuthStore(key: String) -> ZAIAuthStore {