diff --git a/Sources/QuotaCore/AntigravityQuotaGroups.swift b/Sources/QuotaCore/AntigravityQuotaGroups.swift index 1dd669e4..99b796de 100644 --- a/Sources/QuotaCore/AntigravityQuotaGroups.swift +++ b/Sources/QuotaCore/AntigravityQuotaGroups.swift @@ -98,29 +98,24 @@ public enum AntigravityQuotaGroups { /// The pool keys, in display order. public static let poolKeys = ["gemini", "third-party"] - /// The level a weekly cap has to reach before the pool counts as spent. - /// - /// The provider reports a fractional remaining, 0.4% is ordinary, and the - /// interface rounds that to 0%, so a cap that reads "at cap" on screen can - /// carry a number that is not exactly zero. An exact-zero test let those - /// through, and the five-hour window under them was then believed when its - /// percentage must not be believed: owner 2026-10-19, "Antigravity is - /// saying vendor reset 5h quota early while the 7d is still at cap and - /// unchanged". Half a point is where the interface rounds, so the mask - /// and the interface agree on what "at cap" means. - /// - /// The number itself lives in `QuotaCapRule.spentThreshold`, so the pool's - /// own weekly test and the shared cap rule cannot drift apart. - public static let weeklySpentThreshold: Double = QuotaCapRule.spentThreshold - /// Whether a pool's weekly cap is spent. A weekly that reports a number /// is spent at or below the rounding threshold; one that reports no number /// falls back to the provider's own word for it. + /// + /// The threshold is `QuotaCapRule.spentThreshold` rather than a second + /// constant kept here: the pool used to carry its own copy, and once the + /// weekly decision moved to the shared rule nothing read that copy, so it + /// survived only as dead code kept alive by a test -- which is how a live + /// copy of the same question could drift elsewhere without failing + /// anything. The number itself, and the reason it is half a point, live + /// in `QuotaCapRule`: the provider reports a fractional remaining and the + /// interface rounds 0.4% to 0%, so an exact-zero test let a cap through + /// and the five-hour window under it was then believed when its percentage + /// must not be (owner 2026-10-19, "Antigravity is saying vendor reset 5h + /// quota early while the 7d is still at cap and unchanged"). private static func weeklyIsSpent(_ window: QuotaWindow) -> Bool { - if let remaining = window.boundedRemainingPercent { - return remaining <= weeklySpentThreshold - } - return window.isExhausted + QuotaCapRule.isExhausted(remainingPercent: window.boundedRemainingPercent, + providerSaysExhausted: window.isExhausted) } /// Splits Antigravity windows into their two pools. Windows that belong to @@ -171,8 +166,9 @@ public enum AntigravityQuotaGroups { let headline = driving ?? spentWeekly result.append(Pool(key: key, windows: ordered, - remainingPercent: headline?.boundedRemainingPercent - ?? (weeklyExhausted ? 0 : nil), + remainingPercent: QuotaCapRule.publishedReading( + remainingPercent: headline?.boundedRemainingPercent, + allowanceSpent: weeklyExhausted), weeklyExhausted: weeklyExhausted, drivingWindowId: headline?.id, resetAt: headline?.resetDate, diff --git a/Sources/QuotaCore/QuotaWindowVocabulary.swift b/Sources/QuotaCore/QuotaWindowVocabulary.swift index 00baf3ad..c2776599 100644 --- a/Sources/QuotaCore/QuotaWindowVocabulary.swift +++ b/Sources/QuotaCore/QuotaWindowVocabulary.swift @@ -333,7 +333,13 @@ public enum QuotaWindowVocabulary { public static func atCapCadence(cadence: String, resetAt: Date? = nil) -> String { let known = [fiveHour, oneDay, sevenDay, oneMonth] if known.contains(cadence) { return cadence } - return standardCadence(windowToken: cadence, label: "", resetAt: resetAt, monthlyHint: false) + // The cadence is a token, so the trailing " window" a caller may still + // carry is stripped before the token is resolved: "7-day window" names + // the same period as "7-day". + return standardCadence(windowToken: stripTrailingWindowSuffix(cadence), + label: "", + resetAt: resetAt, + monthlyHint: false) } /// The countdown an at-cap line quotes: the two largest non-zero units of @@ -678,6 +684,49 @@ public enum QuotaCapRule { return bounded <= spentThreshold } + /// Whether an allowance is spent, when the provider's own word and its + /// reading can disagree. + /// + /// The reading decides whenever there is one: the Mac's export never + /// clears `isExhausted`, so a provider that still carries the flag long + /// after the allowance came back would otherwise keep reporting a spent + /// allowance, and the Mac and the phone would tell the owner different + /// things about one account. With no reading at all the provider's word is + /// the only evidence there is, so it counts. + /// + /// This is the one place that decides, so no surface can carry its own + /// version of the question. + /// + /// A reading exists when `bounded` can make one. A value that is not a + /// finite number is no reading at all, so it falls back to the provider's + /// word rather than passing the existence check and then being judged as + /// headroom, which is what the phone's previous `(pct ?? 100) <= 0 || flag` + /// never did. + public static func isExhausted(remainingPercent: Double?, providerSaysExhausted: Bool) -> Bool { + guard let reading = bounded(remainingPercent) else { return providerSaysExhausted } + return reading <= spentThreshold + } + + /// The reading a row publishes for an allowance. + /// + /// An allowance the provider reports as gone without a figure still has to + /// publish a reading, because the cap rule only binds a window that + /// carries one: a row with no percentage reports no cap at all, so the + /// cap line never appears beside it and the countdown it should quote is + /// lost. A spent allowance publishes zero. + /// + /// The Mac's Antigravity pool grouper and the phone's own row synthesis + /// both do this, and both do it through here, so the two cannot drift. + /// + /// A published reading is bounded here, so every caller publishes the same + /// valid percentage: the phone passes the raw wire value, and an + /// out-of-range one would otherwise render as "150%" while the cap rule + /// judged the same window at 100. + public static func publishedReading(remainingPercent: Double?, allowanceSpent: Bool) -> Double? { + if let reading = bounded(remainingPercent) { return reading } + return allowanceSpent ? 0 : nil + } + /// Whether this window is a supplementary video allowance beside /// MiniMax's coding subscription rather than inside it. /// diff --git a/Tests/CodeCapsTests/CompanionModelTests.swift b/Tests/CodeCapsTests/CompanionModelTests.swift index 6fc88a4f..89d627f1 100644 --- a/Tests/CodeCapsTests/CompanionModelTests.swift +++ b/Tests/CodeCapsTests/CompanionModelTests.swift @@ -360,19 +360,102 @@ final class CompanionModelTests: XCTestCase { /// interface rounds that to 0%. The phone tested for exactly zero, so the /// same pool was capped on one surface and not on the other. /// - /// The phone now reads the shared rule's threshold instead of carrying its - /// own number, so the two surfaces cannot drift again. + /// The phone now asks the shared rule for the answer rather than carrying a + /// threshold of its own. The constant it used to keep is deleted: once + /// the weekly decision moved into the rule, nothing read it, and a test + /// that pinned a dead symbol still passed while a live copy of the same + /// question sat in the widget -- so the guarantee it claimed was false + /// (Kody review 2026-10-22). func testThePhoneAndTheMacAgreeOnWhenAWeeklyCapCountsAsSpent() throws { let model = try readCompanionModelSource() + XCTAssertFalse( + model.contains("weeklySpentThreshold"), + "the phone must not keep a weekly spent threshold of its own; the shared rule owns the number" + ) XCTAssertTrue( - model.contains("public static let weeklySpentThreshold: Double = QuotaCapRule.spentThreshold"), - "the phone must read the shared threshold, not carry a number of its own" + model.contains("let weeklyExhausted = QuotaCapRule.isExhausted(remainingPercent: weeklyPct,"), + "the phone must decide the weekly through the shared rule, so a reading outranks the provider's exhausted word" + ) + } + + /// A pool is spent when any of its windows is. + /// + /// The pool decision evaluated the provider's word against `controllingPct`, + /// the lowest reading across the pool -- a reading that belongs to a + /// different window than the one the provider reports as gone. A weekly + /// the provider reported as spent without a figure, beside a healthy + /// five-hour reading, therefore reported the plan live and the owner saw a + /// usable plan whose weekly allowance was gone (Kody review 2026-10-22). + /// Each window's own decision already went through the shared rule, so the + /// pool ORs those. + func testAPoolIsSpentWhenAnyOfItsWindowsIs() throws { + let model = try readCompanionModelSource() + XCTAssertTrue( + model.contains("let isExhausted = primaryWindows.contains(where: \\.isExhausted)"), + "the pool must be spent when any of its windows is" ) XCTAssertFalse( - model.contains("public static let weeklySpentThreshold: Double = 0.5"), - "the phone must not keep its own copy of the threshold" + model.contains("remainingPercent: controllingPct,\n providerSaysExhausted: primaryWindows"), + "the provider's word must not be weighed against another window's reading" + ) + } + + /// The weekly row and its own provider word have to agree. + /// + /// `weeklyExhausted` is keyed off `weeklyWin`, picked by scanning ids and + /// labels, while the row is built from `driving`, picked from the period + /// buckets. When the two pick different windows, a weekly the provider + /// reports as gone published no reading, so the cap rule could not bind it + /// and the cap line never appeared (Kody review 2026-10-22). + func testTheWeeklyRowAlsoPublishesZeroForItsOwnProviderWord() throws { + let model = try readCompanionModelSource() + XCTAssertTrue( + model.contains("let providerSaysSpent = driving.isExhausted ?? false"), + "the row must consider the exhausted word of the window it is actually built from" + ) + XCTAssertTrue( + model.contains("allowanceSpent: period == \"weekly\" && (weeklyExhausted || providerSaysSpent)"), + "a weekly the provider reports as gone must publish zero even when the pool's other weekly window differs" + ) + } + + /// The phone must let a reading outrank the provider's exhausted word. + /// + /// The wire never clears `isExhausted`, so a stale flag kept the phone + /// calling a live Antigravity pool exhausted while the Mac kept it live, + /// and the two surfaces told the owner different things about one account + /// (Kody review 2026-10-21). Every place the phone decides "is this + /// spent" now asks the shared rule. + func testThePhoneLetsAReadingOutrankTheProvidersExhaustedWord() throws { + let model = try readCompanionModelSource() + XCTAssertTrue( + model.contains("QuotaCapRule.isExhausted(remainingPercent: pct,"), + "a window row must decide through the shared rule" + ) + XCTAssertFalse( + model.contains("|| (weeklyWin?.isExhausted ?? false)"), + "the wire's exhausted word must not outvote a weekly reading that reports room left" + ) + XCTAssertFalse( + model.contains("(?? 100) <= 0"), + "the phone must not carry its own zero test beside the shared rule" + ) + } + + /// An exhausted weekly that reports no figure still has to publish a + /// reading, or the shared cap rule cannot bind it and the phone's cap line + /// never appears (Kody review 2026-10-21). The Mac does the same in its + /// Antigravity pool grouper. + func testAnExhaustedWeeklyWithNoFigureStillPublishesAReading() throws { + let model = try readCompanionModelSource() + XCTAssertTrue( + model.contains("QuotaCapRule.publishedReading("), + "the phone must publish its reading through the shared rule" + ) + XCTAssertTrue( + model.contains("allowanceSpent: period == \"weekly\" && (weeklyExhausted || providerSaysSpent)"), + "an exhausted weekly with no figure must publish zero" ) - XCTAssertTrue(model.contains("(weeklyPct ?? 100) <= Self.weeklySpentThreshold")) } // MARK: - Local Snapshot Caching and Pull-to-Refresh Parity diff --git a/Tests/CodeCapsTests/WidgetTests.swift b/Tests/CodeCapsTests/WidgetTests.swift index cc997e60..5e8c64e4 100644 --- a/Tests/CodeCapsTests/WidgetTests.swift +++ b/Tests/CodeCapsTests/WidgetTests.swift @@ -79,21 +79,61 @@ final class WidgetTests: XCTestCase { ) } - /// The widget spells the cap rule out rather than importing the module, so - /// its threshold is the one place a copy of the number could creep back in. - /// The widget target compiles `QuotaWindowVocabulary.swift` by path, so - /// `QuotaCapRule` is in scope and the number is read from the rule that - /// owns it. + /// The widget target cannot import QuotaCore as a module, but it compiles + /// `QuotaWindowVocabulary.swift` by path, so the rule that owns the number + /// is in scope. The widget asks that rule now instead of restating the + /// comparison, which is what stops a copy of the question creeping back in + /// here where no test would see it. func testWidgetReadsTheSharedSpentThreshold() throws { let source = try readSource(at: Self.widgetPresentationURL) XCTAssertTrue( - source.contains("percent <= QuotaCapRule.spentThreshold"), - "the widget must read the shared threshold, not carry a number of its own" + source.contains("QuotaCapRule.isExhausted(remainingPercent: percent, providerSaysExhausted: isExhausted)"), + "the widget must ask the shared rule, not carry a number of its own" ) XCTAssertFalse( source.contains("if let percent { return percent <= 0 }"), "the widget must not keep the old exact-zero test" ) + XCTAssertFalse( + source.contains("percent <= QuotaCapRule.spentThreshold"), + "the widget must not restate the threshold comparison beside the rule" + ) + } + + /// The widget and the phone have to answer the same question the same way + /// about one payload. + /// + /// The phone moved to the shared rule while the widget kept its own copy of + /// `(pct ?? 100) <= 0 || flag`, so the widget still believed the export's + /// stale exhausted word over a healthy reading and masked the five-hour row + /// as "n/a" while the app showed live figures (Sentry 18329264, Kody review + /// 2026-10-22). Every copy of that expression has to be gone. + func testWidgetDecidesExhaustionThroughTheSharedRule() throws { + let source = try readSource(at: Self.widgetPresentationURL) + XCTAssertFalse( + source.contains("<= 0 || (w.isExhausted ?? false)"), + "the widget must not keep the old per-window exhaustion test beside the shared rule" + ) + XCTAssertFalse( + source.contains("<= 0 || (weeklyWin?.isExhausted ?? false)"), + "the widget's weekly must be decided through the shared rule too" + ) + XCTAssertFalse( + source.contains("isExhausted: (f.remainingPercent ?? 100) <= 0"), + "the widget's five-hour row must not keep its own zero test" + ) + XCTAssertTrue( + source.contains("let weeklyExhausted = QuotaCapRule.isExhausted(remainingPercent: weeklyPct,"), + "the widget's weekly must be decided through the shared rule" + ) + XCTAssertTrue( + source.contains("let isExhausted = weeklyExhausted || QuotaCapRule.isAtCap(remainingPercent: controllingPct)"), + "a spent weekly must spend the pool even when the five-hour window still reports headroom" + ) + XCTAssertTrue( + source.contains("QuotaCapRule.publishedReading(remainingPercent: w.remainingPercent,"), + "the widget must publish the same bounded reading the phone publishes" + ) } func testProjectYmlDeclaresWidgetTargets() throws { diff --git a/Tests/QuotaCoreTests/QuotaCapRuleTests.swift b/Tests/QuotaCoreTests/QuotaCapRuleTests.swift index 6553b66f..d667af22 100644 --- a/Tests/QuotaCoreTests/QuotaCapRuleTests.swift +++ b/Tests/QuotaCoreTests/QuotaCapRuleTests.swift @@ -1,3 +1,4 @@ +import Foundation import XCTest @testable import QuotaCore @@ -12,6 +13,20 @@ import XCTest final class QuotaCapRuleTests: XCTestCase { private let now = Date(timeIntervalSince1970: 1_700_000_000) + /// Reads a shipped source file by its path from the repository root, so a + /// test can pin what a surface still spells out for itself. + private func readRepoSource(_ relativePath: String) throws -> String { + let url = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // Tests/QuotaCoreTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // repo root + .appendingPathComponent(relativePath) + guard FileManager.default.fileExists(atPath: url.path) else { + throw XCTSkip("\(relativePath) is not present at \(url.path)") + } + return try String(contentsOf: url, encoding: .utf8) + } + private func facts( id: String, percent: Double?, @@ -60,13 +75,23 @@ final class QuotaCapRuleTests: XCTestCase { /// /// The Antigravity grouper and this rule used to carry the threshold /// separately, one of them at 0.5 and the other at 0, and the owner saw - /// two different answers about one account. - func testEverySurfaceReadsTheSameSpentThreshold() { - XCTAssertEqual( - AntigravityQuotaGroups.weeklySpentThreshold, - QuotaCapRule.spentThreshold, - "the Antigravity pool and the shared cap rule must use one threshold" - ) + /// two different answers about one account. Both copies are gone: the + /// weekly decision reads `QuotaCapRule.isExhausted`, which owns the + /// number. A constant nobody reads is a question nobody keeps in step, + /// and the assertion that used to keep one alive could not catch a live + /// copy of the same question appearing somewhere else (Kody review + /// 2026-10-22). + func testNoSurfaceCarriesItsOwnWeeklySpentThreshold() throws { + for relativePath in [ + "Sources/QuotaCore/AntigravityQuotaGroups.swift", + "ios/CodeCapsCompanion/App/Models/CompanionQuotaModel.swift" + ] { + let source = try readRepoSource(relativePath) + XCTAssertFalse( + source.contains("weeklySpentThreshold"), + "\(relativePath) must not declare a weekly spent threshold of its own; the shared rule owns the number" + ) + } } func testNoReadingIsNotTheCap() { @@ -301,6 +326,121 @@ final class QuotaCapRuleTests: XCTestCase { } } + // MARK: - A reading decides, and the provider's word is the fallback + + /// The shared decision, tested where both surfaces can reach it. + /// + /// The Mac's export never clears `isExhausted`, so a provider that still + /// carries the flag long after the allowance came back would keep + /// reporting a spent allowance. The phone used to let that flag outvote + /// a reading that said there was room left (Kody review 2026-10-21). + func testAReadingOutranksTheProvidersExhaustedWord() { + XCTAssertTrue(QuotaCapRule.isExhausted(remainingPercent: 0, providerSaysExhausted: false)) + XCTAssertTrue(QuotaCapRule.isExhausted(remainingPercent: 0.4, providerSaysExhausted: false)) + XCTAssertFalse(QuotaCapRule.isExhausted(remainingPercent: 50, providerSaysExhausted: true)) + XCTAssertFalse(QuotaCapRule.isExhausted(remainingPercent: 100, providerSaysExhausted: true)) + } + + /// With no reading at all, the provider's word is the only evidence there + /// is, so it counts. + func testWithNoReadingTheProvidersWordIsTheOnlyEvidence() { + XCTAssertTrue(QuotaCapRule.isExhausted(remainingPercent: nil, providerSaysExhausted: true)) + XCTAssertFalse(QuotaCapRule.isExhausted(remainingPercent: nil, providerSaysExhausted: false)) + } + + /// A spent allowance with no figure still publishes a reading, so the cap + /// rule has something to bind and the cap line can quote its countdown. + func testASpentAllowanceWithNoFigureStillPublishesAReading() { + XCTAssertEqual(QuotaCapRule.publishedReading(remainingPercent: nil, allowanceSpent: true), 0) + XCTAssertNil(QuotaCapRule.publishedReading(remainingPercent: nil, allowanceSpent: false)) + XCTAssertEqual(QuotaCapRule.publishedReading(remainingPercent: 42, allowanceSpent: true), 42) + XCTAssertEqual(QuotaCapRule.publishedReading(remainingPercent: 42, allowanceSpent: false), 42) + } + + /// A reading that is not a number is not a reading at all. + /// + /// The rule decided that a reading existed with `remainingPercent != nil` + /// and then judged it through `bounded`, which calls a non-finite value no + /// reading. So a NaN window passed the existence check, was reported as + /// headroom, and the provider's exhausted word -- the only evidence there + /// was -- was dropped. The phone's previous `(pct ?? 100) <= 0 || flag` + /// never did that (Kody review 2026-10-22). + func testANonFiniteReadingFallsBackToTheProvidersWord() { + XCTAssertTrue(QuotaCapRule.isExhausted(remainingPercent: .nan, providerSaysExhausted: true)) + XCTAssertFalse(QuotaCapRule.isExhausted(remainingPercent: .nan, providerSaysExhausted: false)) + XCTAssertTrue(QuotaCapRule.isExhausted(remainingPercent: .infinity, providerSaysExhausted: true)) + XCTAssertFalse(QuotaCapRule.isExhausted(remainingPercent: -.infinity, providerSaysExhausted: false)) + } + + /// A reading outside 0...100 is judged as what it can actually mean: a + /// negative one is a spent allowance, an impossible one is headroom, and + /// neither one lets a stale flag overrule it. + func testAnOutOfRangeReadingIsJudgedAsItsBoundedValue() { + XCTAssertTrue(QuotaCapRule.isExhausted(remainingPercent: -5, providerSaysExhausted: false)) + XCTAssertFalse(QuotaCapRule.isExhausted(remainingPercent: 150, providerSaysExhausted: true)) + } + + /// Every surface publishes the same bounded percentage. + /// + /// The Mac's grouper passes an already-clamped value and the phone passes + /// the raw wire value, so an unbounded helper published "150%" on the + /// phone while the rule judged that window at 100 -- two readings for one + /// window, which is the drift this helper exists to prevent + /// (Kody review 2026-10-22). + func testAPublishedReadingIsBoundedForEveryCaller() { + XCTAssertEqual(QuotaCapRule.publishedReading(remainingPercent: 150, allowanceSpent: false), 100) + XCTAssertEqual(QuotaCapRule.publishedReading(remainingPercent: -5, allowanceSpent: false), 0) + XCTAssertNil(QuotaCapRule.publishedReading(remainingPercent: .nan, allowanceSpent: false)) + XCTAssertEqual(QuotaCapRule.publishedReading(remainingPercent: .nan, allowanceSpent: true), 0) + } + + /// The phone's missing cap line, in the rule both surfaces share. + /// + /// The phone's Antigravity row synthesis published a weekly row with no + /// percentage when the provider reported the allowance as gone without a + /// figure, and the rule only binds a window that carries a reading, so + /// the cap line never appeared (Kody review 2026-10-21). A published zero + /// binds it. + func testASpentAllowanceThatPublishedZeroBindsTheCap() { + let windows = [ + facts( + id: "weekly", + percent: QuotaCapRule.publishedReading(remainingPercent: nil, allowanceSpent: true), + resetsIn: 86_400 + ) + ] + XCTAssertEqual(QuotaCapRule.bindingWindowIndex(in: windows), 0) + XCTAssertEqual(QuotaCapRule.bindingWindow(in: windows)?.id, "weekly") + } + + // MARK: - The binding window is found by position + + /// One list can carry two snapshots of the same window id, the same window + /// reported by two sources, so the window the rule binds is carried by + /// position rather than re-found by id afterwards (Kody review 2026-10-21). + func testADuplicateIdStillBindsTheWindowThatResetsLast() { + let windows = [ + facts(id: "weekly", percent: 0, resetsIn: 3_600), + facts(id: "weekly", percent: 0, resetsIn: 86_400) + ] + XCTAssertEqual(QuotaCapRule.bindingWindowIndex(in: windows), 1) + XCTAssertEqual(QuotaCapRule.bindingWindow(in: windows)?.resetAt, windows[1].resetAt) + } + + // MARK: - A cadence is a token, never prose + + /// The at-cap line resolves a cadence through whole lexical tokens, so a + /// value such as "notweekly" names no period at all (Kody rule 40, + /// binding 2026-10-21). + func testACadenceNamesItsPeriodThroughWholeTokensOnly() { + XCTAssertEqual(QuotaWindowVocabulary.atCapCadence(cadence: "7-day"), "7-day") + XCTAssertEqual(QuotaWindowVocabulary.atCapCadence(cadence: "5-hour"), "5-hour") + XCTAssertEqual(QuotaWindowVocabulary.atCapCadence(cadence: "weekly"), "7-day") + XCTAssertEqual(QuotaWindowVocabulary.atCapCadence(cadence: "7-day window"), "7-day") + XCTAssertEqual(QuotaWindowVocabulary.atCapCadence(cadence: "notweekly"), "Other limit") + XCTAssertEqual(QuotaWindowVocabulary.atCapCadence(cadence: "notdaily"), "Other limit") + } + private func snapshot( id: String, remaining: Double?, diff --git a/ios/CodeCapsCompanion/App/Models/CompanionQuotaModel.swift b/ios/CodeCapsCompanion/App/Models/CompanionQuotaModel.swift index 240ba952..0e133d6a 100644 --- a/ios/CodeCapsCompanion/App/Models/CompanionQuotaModel.swift +++ b/ios/CodeCapsCompanion/App/Models/CompanionQuotaModel.swift @@ -1765,7 +1765,12 @@ public final class CompanionQuotaModel: ObservableObject { resetAt: parsedReset ) let pct = w.remainingPercent - let exhausted = (pct ?? 100) <= 0 || (w.isExhausted ?? false) + // A reading decides, as it does on every surface: the export never + // clears `isExhausted`, so a stale word must not outvote a reading + // that says there is room left. The provider's word is the + // fallback for a window that reports no figure at all. + let exhausted = QuotaCapRule.isExhausted(remainingPercent: pct, + providerSaysExhausted: w.isExhausted ?? false) let modelQualifier = (w.modelId ?? "").lowercased() let isMiniMax = providerKey.lowercased().contains("minimax") || id.lowercased().contains("minimax") @@ -1851,7 +1856,13 @@ public final class CompanionQuotaModel: ObservableObject { // Controlling percentage is the minimum among primary non-masked windows let validPercents = primaryWindows.compactMap(\.remainingPercent) let controllingPct = validPercents.min() - let isExhausted = (controllingPct ?? 100) <= 0 || primaryWindows.contains(where: \.isExhausted) + // Each window above already decided through the shared rule, so the + // pool is exhausted when any of them is. Evaluating the provider's + // word against `controllingPct` instead would compare a reading that + // belongs to a *different* window with the word of the window that + // has no reading at all: a weekly the provider reports as gone, + // beside a healthy five-hour reading, would report the plan live. + let isExhausted = primaryWindows.contains(where: \.isExhausted) // Nearest future reset time let now = Date() @@ -1882,19 +1893,6 @@ public final class CompanionQuotaModel: ObservableObject { ) } - /// The level a weekly cap has to reach before the pool counts as spent. - /// - /// The Mac groups Antigravity's pools with `AntigravityQuotaGroups.weeklySpentThreshold`, - /// which is 0.5: the interface rounds 0.4% to 0%, so a cap that reads "at - /// cap" on one surface can carry a number that is not exactly zero. The - /// phone used to test for exactly zero, so the same pool was capped on the - /// Mac and not on the phone, and each surface told the owner a different - /// thing about the same account. - /// - /// The number now comes from the shared rule rather than being written out - /// here, so the phone, the Mac and the widget all read one threshold. - public static let weeklySpentThreshold: Double = QuotaCapRule.spentThreshold - private func consolidateAntigravityPool( poolKey: String, title: String, @@ -1914,7 +1912,13 @@ public final class CompanionQuotaModel: ObservableObject { } let weeklyPct = weeklyWin?.remainingPercent - let weeklyExhausted = (weeklyPct ?? 100) <= Self.weeklySpentThreshold || (weeklyWin?.isExhausted ?? false) + // A reading decides, exactly as the Mac's `AntigravityQuotaGroups.weeklyIsSpent` + // and the shared rule do: the export never clears `isExhausted`, so + // letting the wire's exhausted word override a healthy reading made + // the phone call a live pool exhausted while the Mac kept it live, and + // the two surfaces told the owner different things about one account. + let weeklyExhausted = QuotaCapRule.isExhausted(remainingPercent: weeklyPct, + providerSaysExhausted: weeklyWin?.isExhausted ?? false) let controllingPct: Double? let isExhausted: Bool @@ -1931,11 +1935,11 @@ public final class CompanionQuotaModel: ObservableObject { if validPercents.isEmpty { let allPcts = windows.compactMap { $0.remainingPercent } controllingPct = allPcts.min() - isExhausted = (controllingPct ?? 100) <= 0 + isExhausted = QuotaCapRule.isAtCap(remainingPercent: controllingPct) subtitle = "\(QuotaWindowVocabulary.fiveHour) & \(QuotaWindowVocabulary.sevenDay)\(sourceSuffix)" } else { controllingPct = validPercents.min() - isExhausted = (controllingPct ?? 100) <= 0 + isExhausted = QuotaCapRule.isAtCap(remainingPercent: controllingPct) subtitle = "\(QuotaWindowVocabulary.fiveHour) & \(QuotaWindowVocabulary.sevenDay)\(sourceSuffix)" } } @@ -2035,8 +2039,25 @@ public final class CompanionQuotaModel: ObservableObject { } guard let driving else { return nil } - let pct = driving.remainingPercent - let exhausted = (pct ?? 100) <= 0 || (driving.isExhausted ?? false) + // A weekly the provider reports as gone without a figure still has to + // publish a reading: the shared cap rule only binds a window that + // carries one, so without it the phone's cap line never appears. + // The Mac does the same in `AntigravityQuotaGroups.pools`. + // + // The row's own provider word counts as well as the pool's: + // `weeklyExhausted` is keyed off `weeklyWin`, chosen by scanning + // ids and labels, while this row is built from `driving`, chosen + // from the period buckets. When the two pick different windows a + // weekly the provider reports as gone would publish no reading and + // never bind, or would publish a healthy reading under a headline + // that says the weekly is exhausted. A reading still outranks the + // word, so this only decides the reading-less case. + let providerSaysSpent = driving.isExhausted ?? false + let pct = QuotaCapRule.publishedReading( + remainingPercent: driving.remainingPercent, + allowanceSpent: period == "weekly" && (weeklyExhausted || providerSaysSpent)) + let exhausted = QuotaCapRule.isExhausted(remainingPercent: pct, + providerSaysExhausted: providerSaysSpent) let parsedReset = CompanionDateFormatter.date(from: driving.resetAt) let cadenceToken = period == "5h" ? QuotaWindowVocabulary.fiveHour : QuotaWindowVocabulary.sevenDay let displayLabel = QuotaWindowVocabulary.windowLabel( @@ -2295,7 +2316,12 @@ public final class CompanionQuotaModel: ObservableObject { observedAt <= Date(), observedAt > (exhaustionObservedAt[key] ?? .distantPast) else { continue } exhaustionObservedAt[key] = observedAt - let exhausted = window.isExhausted || (window.remainingPercent ?? 100) <= 0 + // The shared cap rule decides, exactly as the Mac's own + // exhaustion path does: the reading decides when there is + // one, and the provider's own exhausted word is the fallback + // for a window that reports no figure at all. + let exhausted = QuotaCapRule.isExhausted(remainingPercent: window.remainingPercent, + providerSaysExhausted: window.isExhausted) let previous = exhaustionState[key] exhaustionState[key] = exhausted let eventKey = Self.quotaEventKey(kind: .quotaExhaustion, provider: item.providerKey, label: window.cadence, diff --git a/ios/CodeCapsCompanion/Widgets/WidgetPresentation.swift b/ios/CodeCapsCompanion/Widgets/WidgetPresentation.swift index b93c4e60..69ba44c5 100644 --- a/ios/CodeCapsCompanion/Widgets/WidgetPresentation.swift +++ b/ios/CodeCapsCompanion/Widgets/WidgetPresentation.swift @@ -313,22 +313,22 @@ public struct WidgetPlatformItem: Identifiable, Equatable, Sendable { /// Pure presentation logic and parser for WidgetKit widgets. public enum WidgetPresentation { - /// Whether a window's allowance is spent. Mirrors - /// `QuotaWindowSnapshot.isAtCap` in QuotaCore, which the widget target - /// cannot import as a module (it includes QuotaCore files by path rather - /// than as a module), so the rule is stated twice on purpose: one owner - /// ruling, two spellings. + /// Whether a window's allowance is spent. /// - /// The threshold itself is not restated. The widget target compiles - /// `QuotaWindowVocabulary.swift`, so `QuotaCapRule` is in scope here and - /// the number is read from the one place that owns it. + /// The widget target cannot import QuotaCore as a module (it compiles its + /// files by path rather than linking them), but the decision is not + /// restated here anyway: the target compiles `QuotaWindowVocabulary.swift`, + /// so `QuotaCapRule` is in scope and this asks the one rule that owns the + /// question. /// - /// The reading wins whenever there is one, because QuotaCore only ever sets - /// `isExhausted` and never clears it; believing the flag over a fresh - /// positive reading would report a cap that no longer exists. + /// It used to spell the comparison out here, and then the app and the + /// widget answered differently about one payload: the app let a healthy + /// weekly reading outrank the export's stale `isExhausted`, while the + /// widget still let the flag win and masked the five-hour row as "n/a" + /// (Sentry 18329264, Kody review 2026-10-22). Asking the rule is what + /// keeps them in step. public static func isAtCap(percent: Double?, isExhausted: Bool) -> Bool { - if let percent { return percent <= QuotaCapRule.spentThreshold } - return isExhausted + QuotaCapRule.isExhausted(remainingPercent: percent, providerSaysExhausted: isExhausted) } public static func displayPercent(percent: Double?, isMasked: Bool) -> String { @@ -650,7 +650,11 @@ public enum WidgetPresentation { let cadence = formatCadence(w.label, window: w.window, resetAt: parsedReset) let observed = parseDate(from: w.occurredAt) let pct = w.remainingPercent - let exhausted = (pct ?? 100) <= 0 || (w.isExhausted ?? false) + // The one shared rule decides, as it does on the phone and on the + // Mac: a reading outranks the export's stale exhausted word, and + // the word is the fallback for a window that reports no figure. + let exhausted = QuotaCapRule.isExhausted(remainingPercent: pct, + providerSaysExhausted: w.isExhausted ?? false) let displayLabel = QuotaWindowVocabulary.windowLabel( windowToken: w.window, existingLabel: w.label.isEmpty ? cadence : w.label, @@ -758,17 +762,24 @@ public enum WidgetPresentation { } let weeklyPct = weeklyWin?.remainingPercent - let weeklyExhausted = (weeklyPct ?? 100) <= 0 || (weeklyWin?.isExhausted ?? false) + let weeklyExhausted = QuotaCapRule.isExhausted(remainingPercent: weeklyPct, + providerSaysExhausted: weeklyWin?.isExhausted ?? false) if let f = fiveHourWin { let reset = parseDate(from: f.resetAt) + let fiveHourSpent = QuotaCapRule.isExhausted(remainingPercent: f.remainingPercent, + providerSaysExhausted: f.isExhausted ?? false) childWindows.append(WidgetWindowItem( id: f.id, label: "5-hour window", cadence: "5h", - remainingPercent: f.remainingPercent, + // The reading the phone would publish for the same window, so + // a spent allowance the payload left without a figure still + // carries one here and its cap line survives. + remainingPercent: QuotaCapRule.publishedReading(remainingPercent: f.remainingPercent, + allowanceSpent: fiveHourSpent), resetAt: reset, - isExhausted: (f.remainingPercent ?? 100) <= 0, + isExhausted: fiveHourSpent, isMasked: weeklyExhausted )) } @@ -779,7 +790,11 @@ public enum WidgetPresentation { id: w.id, label: "Weekly cap", cadence: "Weekly", - remainingPercent: w.remainingPercent, + // A weekly the provider reports as gone without a figure + // publishes zero here too, so the widget carries the same + // reading as the phone and the Mac for one account. + remainingPercent: QuotaCapRule.publishedReading(remainingPercent: w.remainingPercent, + allowanceSpent: weeklyExhausted), resetAt: reset, isExhausted: weeklyExhausted, isMasked: false @@ -788,7 +803,11 @@ public enum WidgetPresentation { let validPercents = [fiveHourWin?.remainingPercent, weeklyWin?.remainingPercent].compactMap { $0 } let controllingPct = validPercents.min() ?? windows.compactMap(\.remainingPercent).min() - let isExhausted = (controllingPct ?? 100) <= 0 + // The pool rule the phone applies: a weekly the provider reports as + // spent spends the pool even when the five-hour window beside it still + // reports a healthy reading, and the controlling reading decides every + // other case. + let isExhausted = weeklyExhausted || QuotaCapRule.isAtCap(remainingPercent: controllingPct) let nearestReset = childWindows.compactMap(\.resetAt).filter { $0 > now }.min() ?? childWindows.compactMap(\.resetAt).min()