From 290d746d1a4dad0bc97e4aa01ce80df4f83bc8d5 Mon Sep 17 00:00:00 2001 From: leo Date: Wed, 11 Nov 2026 14:36:26 +0800 Subject: [PATCH] fix(models): only the next Claude limit reset can be used; say so plainly The server takes resets only in its own order: claiming any but its next_grant_id is refused as ineligible / not_next_grant, as Claude Code's CLI has it. #887 gave every reset a button; back to one, on the reset that can be used now, listed first and framed in the accent. The others say they wait their turn, the hover says resets go one at a time in Anthropic's order, and a refusal (not the next one, not at a limit, already used, cooling down) is told in plain words, not as the server's codes. Co-Authored-By: BaoCode --- lib/l10n/app_en.arb | 10 ++- lib/l10n/app_localizations.dart | 34 +++++++-- lib/l10n/app_localizations_en.dart | 23 +++++- lib/l10n/app_localizations_zh.dart | 19 ++++- lib/l10n/app_zh.arb | 10 ++- lib/models/claude_resets.dart | 59 ++++++++------- lib/settings/pages/models_page.dart | 107 +++++++++++++--------------- test/models/claude_resets_test.dart | 53 +++++++------- test/settings/models_page_test.dart | 59 ++++++++++++--- 9 files changed, 234 insertions(+), 140 deletions(-) diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 60bd4896d..eb8416834 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -8596,15 +8596,15 @@ "claudeResets": "{count, plural, =1{1 reset} other{{count} resets}}", "@claudeResets": {"placeholders": {"count": {"type": "int"}}}, "claudeResetsTitle": "Claude limit resets", - "claudeResetsDetail": "Granted by Anthropic. Using one sets the limits it lists back to nothing used.", + "claudeResetsDetail": "Granted by Anthropic. Using one sets the limits it lists back to nothing used. With more than one, they are used one at a time, in the order Anthropic sets.", "claudeResetClears": "Clears {windows}", "@claudeResetClears": {"placeholders": {"windows": {"type": "String"}}}, "claudeResetWindowUsed": "{window} ({percent}% used)", "@claudeResetWindowUsed": {"placeholders": {"window": {"type": "String"}, "percent": {"type": "int"}}}, "claudeResetWeeklyOf": "{model} weekly", "@claudeResetWeeklyOf": {"placeholders": {"model": {"type": "String"}}}, - "claudeResetNext": "Default", - "claudeResetNextHint": "The one the server would take first", + "claudeResetNext": "Usable now", + "claudeResetQueued": "Waits its turn: usable once the one above is used", "claudeResetUse": "Use This One…", "claudeResetConfirm": "Use a Claude limit reset?", "claudeResetConfirmDetail": "“{label}” sets {windows} back to nothing used, at once. The reset can't be given back, and what is left of these limits now is lost.", @@ -8619,6 +8619,10 @@ "@claudeResetDone": {"placeholders": {"windows": {"type": "String"}}}, "claudeResetNotDone": "Not reset: {reason}", "@claudeResetNotDone": {"placeholders": {"reason": {"type": "String"}}}, + "claudeResetNotNext": "Another reset is the one usable now; nothing was used. Refresh and try again.", + "claudeResetNotLimited": "This reset can only be used once a limit is reached; nothing was used.", + "claudeResetAlreadyUsed": "This reset was already used; nothing was used now.", + "claudeResetCoolingDown": "A reset was just used; wait a little before the next. Nothing was used.", "claudeResetsRefresh": "Refresh", "resetCreditsHint": "{count, plural, =1{A reset credit expires} other{{count} reset credits expire}} within a week. To get their worth, use Codex models on this plan, resetting once the quota runs out:", "@resetCreditsHint": {"placeholders": {"count": {"type": "int"}}}, diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 0306819e1..31c88fd88 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -21652,7 +21652,7 @@ abstract class AppLocalizations { /// No description provided for @claudeResetsDetail. /// /// In en, this message translates to: - /// **'Granted by Anthropic. Using one sets the limits it lists back to nothing used.'** + /// **'Granted by Anthropic. Using one sets the limits it lists back to nothing used. With more than one, they are used one at a time, in the order Anthropic sets.'** String get claudeResetsDetail; /// No description provided for @claudeResetClears. @@ -21676,14 +21676,14 @@ abstract class AppLocalizations { /// No description provided for @claudeResetNext. /// /// In en, this message translates to: - /// **'Default'** + /// **'Usable now'** String get claudeResetNext; - /// No description provided for @claudeResetNextHint. + /// No description provided for @claudeResetQueued. /// /// In en, this message translates to: - /// **'The one the server would take first'** - String get claudeResetNextHint; + /// **'Waits its turn: usable once the one above is used'** + String get claudeResetQueued; /// No description provided for @claudeResetUse. /// @@ -21739,6 +21739,30 @@ abstract class AppLocalizations { /// **'Not reset: {reason}'** String claudeResetNotDone(String reason); + /// No description provided for @claudeResetNotNext. + /// + /// In en, this message translates to: + /// **'Another reset is the one usable now; nothing was used. Refresh and try again.'** + String get claudeResetNotNext; + + /// No description provided for @claudeResetNotLimited. + /// + /// In en, this message translates to: + /// **'This reset can only be used once a limit is reached; nothing was used.'** + String get claudeResetNotLimited; + + /// No description provided for @claudeResetAlreadyUsed. + /// + /// In en, this message translates to: + /// **'This reset was already used; nothing was used now.'** + String get claudeResetAlreadyUsed; + + /// No description provided for @claudeResetCoolingDown. + /// + /// In en, this message translates to: + /// **'A reset was just used; wait a little before the next. Nothing was used.'** + String get claudeResetCoolingDown; + /// No description provided for @claudeResetsRefresh. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index ad590eae3..f83b0077a 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -13270,7 +13270,7 @@ class AppLocalizationsEn extends AppLocalizations { @override String get claudeResetsDetail => - 'Granted by Anthropic. Using one sets the limits it lists back to nothing used.'; + 'Granted by Anthropic. Using one sets the limits it lists back to nothing used. With more than one, they are used one at a time, in the order Anthropic sets.'; @override String claudeResetClears(String windows) { @@ -13288,10 +13288,11 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String get claudeResetNext => 'Default'; + String get claudeResetNext => 'Usable now'; @override - String get claudeResetNextHint => 'The one the server would take first'; + String get claudeResetQueued => + 'Waits its turn: usable once the one above is used'; @override String get claudeResetUse => 'Use This One…'; @@ -13331,6 +13332,22 @@ class AppLocalizationsEn extends AppLocalizations { return 'Not reset: $reason'; } + @override + String get claudeResetNotNext => + 'Another reset is the one usable now; nothing was used. Refresh and try again.'; + + @override + String get claudeResetNotLimited => + 'This reset can only be used once a limit is reached; nothing was used.'; + + @override + String get claudeResetAlreadyUsed => + 'This reset was already used; nothing was used now.'; + + @override + String get claudeResetCoolingDown => + 'A reset was just used; wait a little before the next. Nothing was used.'; + @override String get claudeResetsRefresh => 'Refresh'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 0ba7d8e62..f67204903 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -12555,7 +12555,8 @@ class AppLocalizationsZh extends AppLocalizations { String get claudeResetsTitle => 'Claude 重置卡'; @override - String get claudeResetsDetail => 'Anthropic 赠送。用掉一张,它列出的额度立即清零。'; + String get claudeResetsDetail => + 'Anthropic 赠送。用掉一张,它列出的额度立即清零。有多张时要按 Anthropic 排好的顺序一张一张用。'; @override String claudeResetClears(String windows) { @@ -12573,10 +12574,10 @@ class AppLocalizationsZh extends AppLocalizations { } @override - String get claudeResetNext => '默认'; + String get claudeResetNext => '现在可用'; @override - String get claudeResetNextHint => '服务器默认先用这张'; + String get claudeResetQueued => '排在后面,用掉上面那张后才能用'; @override String get claudeResetUse => '使用这张…'; @@ -12615,6 +12616,18 @@ class AppLocalizationsZh extends AppLocalizations { return '没有重置:$reason'; } + @override + String get claudeResetNotNext => '现在能用的换成了另一张,这次什么都没扣。刷新看看再用。'; + + @override + String get claudeResetNotLimited => '这张卡要等额度用满了才能用,这次什么都没扣。'; + + @override + String get claudeResetAlreadyUsed => '这张卡已经用过了,这次什么都没扣。'; + + @override + String get claudeResetCoolingDown => '刚用过一张,要稍等一会儿才能再用,这次什么都没扣。'; + @override String get claudeResetsRefresh => '刷新'; diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index fbc00c485..e5ed5dbe9 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -3598,12 +3598,12 @@ "modelsResetCreditConfirmDetail": "额度会立即重置,卡用掉后不能退回;现在还没用完的额度也就作废了。", "claudeResets": "重置卡 {count} 张", "claudeResetsTitle": "Claude 重置卡", - "claudeResetsDetail": "Anthropic 赠送。用掉一张,它列出的额度立即清零。", + "claudeResetsDetail": "Anthropic 赠送。用掉一张,它列出的额度立即清零。有多张时要按 Anthropic 排好的顺序一张一张用。", "claudeResetClears": "清零:{windows}", "claudeResetWindowUsed": "{window}(已用 {percent}%)", "claudeResetWeeklyOf": "{model} 每周", - "claudeResetNext": "默认", - "claudeResetNextHint": "服务器默认先用这张", + "claudeResetNext": "现在可用", + "claudeResetQueued": "排在后面,用掉上面那张后才能用", "claudeResetUse": "使用这张…", "claudeResetConfirm": "用掉一张 Claude 重置卡?", "claudeResetConfirmDetail": "「{label}」会把 {windows} 立即清零。卡用掉后不能退回,这些额度现在还没用完的部分也就作废了。", @@ -3613,6 +3613,10 @@ "claudeResetSignedOut": "Claude.ai 登录已过期,Claude Code 下次运行时会自动续上。", "claudeResetDone": "已重置:{windows}", "claudeResetNotDone": "没有重置:{reason}", + "claudeResetNotNext": "现在能用的换成了另一张,这次什么都没扣。刷新看看再用。", + "claudeResetNotLimited": "这张卡要等额度用满了才能用,这次什么都没扣。", + "claudeResetAlreadyUsed": "这张卡已经用过了,这次什么都没扣。", + "claudeResetCoolingDown": "刚用过一张,要稍等一会儿才能再用,这次什么都没扣。", "claudeResetsRefresh": "刷新", "resetCreditsHint": "有 {count} 张重置卡将在一周内过期。建议按这个计划用 Codex 模型,额度用满后再重置,别让它们白白过期:", "resetCreditsHintDay": "{day}:重置 {count} 次", diff --git a/lib/models/claude_resets.dart b/lib/models/claude_resets.dart index d4c09507a..b4deaa2d4 100644 --- a/lib/models/claude_resets.dart +++ b/lib/models/claude_resets.dart @@ -99,7 +99,8 @@ class ClaudeResets { final String? reason; final List grants; - /// The one the server would take first, of those the user may pick. + /// The one the server takes next: the only one it lets be used, others + /// refused as `not_next_grant`. final String? nextGrantId; final DateTime? cooldownUntil; @@ -120,21 +121,16 @@ class ClaudeResets { int get count => live.fold(0, (sum, grant) => sum + grant.left); - /// Whether a reset can be taken from [grant] now, whichever the user - /// picks: while the server names one it would take at all. - bool canUse(ClaudeResetGrant grant) => - eligible && - cooldownUntil?.isAfter(at) != true && - nextGrantId != null && - grant.liveAt(at) && - !grant.paused && - grant.usableNow; - - /// Those a reset can be taken from now. - List get usable => [ - for (final grant in live) - if (canUse(grant)) grant, - ]; + /// The grant a reset is taken from now, if one can be. + ClaudeResetGrant? get next { + if (!eligible || cooldownUntil?.isAfter(at) == true) return null; + for (final grant in live) { + if (grant.id == nextGrantId && !grant.paused && grant.usableNow) { + return grant; + } + } + return null; + } /// From a `/api/oauth/usage?cedar_ember=1` answer; null when it has no /// such block (not offered to this account, or not asked). @@ -184,6 +180,10 @@ class ClaudeResetOutcome { bool get reset => result == 'reset'; + /// The server's [reason] when the grant asked for is not the next: only + /// that one can be used. + static const notNext = 'not_next_grant'; + static ClaudeResetOutcome fromJson(Object? json) => switch (json) { {'result': final String result} when result.isNotEmpty => ClaudeResetOutcome( @@ -253,11 +253,8 @@ class ClaudeResetsService extends ChangeNotifier { Future? _loading; DateTime? _askedAt; - bool get using => _usingId != null; - - /// The grant one is being used of. - String? get usingId => _usingId; - String? _usingId; + bool get using => _using; + bool _using = false; Future refresh({bool force = false}) { if (_loading case final loading?) return loading; @@ -286,20 +283,28 @@ class ClaudeResetsService extends ChangeNotifier { } } - /// Uses one of [grant], as it still can be once asked again: what the - /// server answered. Never sent twice: a failure is told, not retried. + /// Uses one of [grant], as it still is the one next once asked again: what + /// the server answered. Never sent twice: a failure is told, not retried. Future use(ClaudeResetGrant grant) async { - if (using) return const ClaudeResetOutcome('busy'); - _usingId = grant.id; + if (_using) return const ClaudeResetOutcome('busy'); + _using = true; notifyListeners(); try { await refresh(force: true); - if (_resets?.usable.any((usable) => usable.id == grant.id) != true) { + final next = _resets?.next; + if (next == null) { return ClaudeResetOutcome( 'unavailable', reason: _error ?? 'no longer offered', ); } + // Another one is next now: as the server would refuse it. + if (next.id != grant.id) { + return const ClaudeResetOutcome( + 'ineligible', + reason: ClaudeResetOutcome.notNext, + ); + } final outcome = ClaudeResetOutcome.fromJson( await _api.claim(grant.id, _requestId()), ); @@ -307,7 +312,7 @@ class ClaudeResetsService extends ChangeNotifier { } on ClaudeResetsException catch (error) { return ClaudeResetOutcome('error', reason: error.message); } finally { - _usingId = null; + _using = false; // What is left of them, and of the windows, now. unawaited(refresh(force: true)); } diff --git a/lib/settings/pages/models_page.dart b/lib/settings/pages/models_page.dart index 5455aa214..2f8852a75 100644 --- a/lib/settings/pages/models_page.dart +++ b/lib/settings/pages/models_page.dart @@ -2607,9 +2607,19 @@ class _ClaudeResetsLabel extends StatelessWidget { _window(l10n, resets, window), ].join(l10n.localeName == 'zh' ? '、' : ', '), ) - : l10n.claudeResetNotDone( - [outcome.result, ?outcome.reason].join(' · '), - ), + : switch (outcome) { + ClaudeResetOutcome(reason: ClaudeResetOutcome.notNext) => + l10n.claudeResetNotNext, + ClaudeResetOutcome(result: 'not_limited') => + l10n.claudeResetNotLimited, + ClaudeResetOutcome(result: 'already_used') => + l10n.claudeResetAlreadyUsed, + ClaudeResetOutcome(result: 'cooldown') => + l10n.claudeResetCoolingDown, + _ => l10n.claudeResetNotDone( + [outcome.result, ?outcome.reason].join(' · '), + ), + }, buttons: [l10n.commonOk], cancel: null, type: outcome.reset ? IdeDialogType.info : IdeDialogType.warning, @@ -2632,9 +2642,10 @@ class _ClaudeResetsLabel extends StatelessWidget { final faint = SettingsText.description.copyWith( color: SettingsColors.textSecondary, ); + final next = resets.next; final why = switch (resets) { _ when service.signedOut => l10n.claudeResetSignedOut, - _ when resets.usable.isNotEmpty => null, + _ when next != null => null, ClaudeResets(:final cooldownUntil?) when cooldownUntil.isAfter(DateTime.now()) => l10n.claudeResetCooldown(_time(cooldownUntil)), @@ -2659,9 +2670,14 @@ class _ClaudeResetsLabel extends StatelessWidget { style: const TextStyle(fontWeight: FontWeight.w600), ), Text(l10n.claudeResetsDetail, style: faint), - for (final grant in resets.live) ...[ + // The usable one first: the others wait for it. + for (final grant in [ + ?next, + for (final grant in resets.live) + if (grant.id != next?.id) grant, + ]) ...[ const SizedBox(height: 8), - _grant(context, resets, grant), + _grant(context, resets, grant, next), ], if (why != null) ...[ const SizedBox(height: 8), @@ -2699,26 +2715,39 @@ class _ClaudeResetsLabel extends StatelessWidget { ); } - /// One grant, framed, with the button that uses it while it can be; the - /// one the server would take first tagged so. + /// One grant, framed: the [next] one, the one a reset is taken from, in + /// the accent with the button that uses it; the others dimmed, queued + /// after it. Widget _grant( BuildContext context, ClaudeResets resets, ClaudeResetGrant grant, + ClaudeResetGrant? next, ) { final l10n = context.l10n; - final usable = resets.canUse(grant); + final isNext = grant.id == next?.id; final faint = SettingsText.description.copyWith( - color: SettingsColors.textSecondary, + color: isNext + ? SettingsColors.textSecondary + : SettingsColors.textTertiary, ); final clears = l10n.claudeResetClears(_clears(l10n, resets, grant)); final meta = [ if (grant.left > 1) '×${grant.left}', if (grant.endsAt case final at?) l10n.modelsResetCreditExpires(_time(at)), if (grant.paused) l10n.claudeResetPaused, + if (next != null && !isNext) l10n.claudeResetQueued, ]; - return _GrantFrame( - lit: usable, + return Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(10, 8, 10, 8), + decoration: BoxDecoration( + color: isNext ? AppColors.accent.withValues(alpha: 0.1) : null, + borderRadius: BorderRadius.circular(6), + border: Border.all( + color: isNext ? AppColors.accent : SettingsColors.stroke, + ), + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -2729,28 +2758,27 @@ class _ClaudeResetsLabel extends StatelessWidget { child: Text( grant.label ?? clears, style: SettingsText.description.copyWith( - color: SettingsColors.textPrimary, - fontWeight: FontWeight.w600, + color: isNext + ? SettingsColors.textPrimary + : SettingsColors.textSecondary, + fontWeight: isNext ? FontWeight.w600 : null, ), ), ), - if (grant.id == resets.nextGrantId) ...[ + if (isNext) ...[ const SizedBox(width: 6), - ModelBadge( - l10n.claudeResetNext, - tooltip: l10n.claudeResetNextHint, - ), + ModelBadge(l10n.claudeResetNext, color: AppColors.accent), ], ], ), if (grant.label != null) Text(clears, style: faint), if (meta.isNotEmpty) Text(meta.join(' · '), style: faint), - if (usable) ...[ + if (isNext) ...[ const SizedBox(height: 8), IdeButton( label: l10n.claudeResetUse, icon: Codicons.debugRestart, - spinning: service.usingId == grant.id, + spinning: service.using, onPressed: service.using ? null : () => unawaited(_use(context, grant)), @@ -2762,43 +2790,6 @@ class _ClaudeResetsLabel extends StatelessWidget { } } -/// A grant's frame: in the accent under the pointer when [lit], so which -/// one its button uses is plain. -class _GrantFrame extends StatefulWidget { - const _GrantFrame({required this.lit, required this.child}); - - final bool lit; - final Widget child; - - @override - State<_GrantFrame> createState() => _GrantFrameState(); -} - -class _GrantFrameState extends State<_GrantFrame> { - bool _hovered = false; - - @override - Widget build(BuildContext context) { - final lit = widget.lit && _hovered; - return MouseRegion( - onEnter: (_) => setState(() => _hovered = true), - onExit: (_) => setState(() => _hovered = false), - child: Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(10, 8, 10, 8), - decoration: BoxDecoration( - color: lit ? AppColors.accent.withValues(alpha: 0.1) : null, - borderRadius: BorderRadius.circular(6), - border: Border.all( - color: lit ? AppColors.accent : SettingsColors.stroke, - ), - ), - child: widget.child, - ), - ); - } -} - /// A window of an account's quota: how long it is, a bar of how much of /// it is used, and when it starts over. class _QuotaLine extends StatelessWidget { diff --git a/test/models/claude_resets_test.dart b/test/models/claude_resets_test.dart index e8fe7134c..75a73f44c 100644 --- a/test/models/claude_resets_test.dart +++ b/test/models/claude_resets_test.dart @@ -98,12 +98,7 @@ void main() { 'launch-2610-fable', ]); expect(resets.count, 2); - expect(resets.nextGrantId, 'full-reset-2611'); - // Either can be picked. - expect(resets.usable.map((g) => g.id), [ - 'full-reset-2611', - 'launch-2610-fable', - ]); + expect(resets.next?.id, 'full-reset-2611'); expect(resets.scopedModel, 'Fable'); final full = resets.live.first; expect(full.clears, [ @@ -116,22 +111,30 @@ void main() { }); test('none to use while ineligible, cooling down, or none named next', () { - for (final usage in [ - claudeUsageWithResets(eligible: false), - claudeUsageWithResets(cooldownUntil: '2026-11-11T03:00:00Z'), - claudeUsageWithResets(next: null), - ]) { - expect(ClaudeResets.fromUsage(usage, now)!.usable, isEmpty); - } + expect( + ClaudeResets.fromUsage(claudeUsageWithResets(eligible: false), now)!.next, + isNull, + ); + expect( + ClaudeResets.fromUsage( + claudeUsageWithResets(cooldownUntil: '2026-11-11T03:00:00Z'), + now, + )!.next, + isNull, + ); + expect( + ClaudeResets.fromUsage(claudeUsageWithResets(next: null), now)!.next, + isNull, + ); // No block: not offered to this account. expect(ClaudeResets.fromUsage({'five_hour': null}, now), isNull); }); - test('a reset is used once, of the grant picked', () async { + test('a reset is used once, of the grant still next', () async { final api = FakeClaudeResetsApi(claudeUsageWithResets()); final service = ClaudeResetsService(api, now: () => now); await service.refresh(); - final grant = service.resets!.usable.first; + final grant = service.resets!.next!; final outcome = await service.use(grant); expect(outcome.reset, isTrue); expect(outcome.cleared, ['five_hour', 'seven_day']); @@ -142,24 +145,16 @@ void main() { ); }); - test('one not the server\'s first is used as picked', () async { - final api = FakeClaudeResetsApi(claudeUsageWithResets()); - final service = ClaudeResetsService(api, now: () => now); - await service.refresh(); - final fable = service.resets!.usable.last; - expect(fable.id, 'launch-2610-fable'); - expect((await service.use(fable)).reset, isTrue); - expect(api.claims.single.$1, 'launch-2610-fable'); - }); - - test('not used when it can no longer be, asked again', () async { + test('not used when the grant is no longer the next', () async { final api = FakeClaudeResetsApi(claudeUsageWithResets()); final service = ClaudeResetsService(api, now: () => now); await service.refresh(); - final grant = service.resets!.usable.first; - api.answer = claudeUsageWithResets(cooldownUntil: '2026-11-11T03:00:00Z'); + final grant = service.resets!.next!; + api.answer = claudeUsageWithResets(next: 'launch-2610-fable'); final outcome = await service.use(grant); expect(outcome.reset, isFalse); + // As the server would have refused it. + expect(outcome.reason, ClaudeResetOutcome.notNext); expect(api.claims, isEmpty); }); @@ -168,7 +163,7 @@ void main() { ..claimAnswer = {'result': 'not_limited', 'reason': 'under the limit'}; final service = ClaudeResetsService(api, now: () => now); await service.refresh(); - final outcome = await service.use(service.resets!.usable.first); + final outcome = await service.use(service.resets!.next!); expect(outcome.reset, isFalse); expect(outcome.result, 'not_limited'); expect(outcome.reason, 'under the limit'); diff --git a/test/settings/models_page_test.dart b/test/settings/models_page_test.dart index 41f85963b..7c85d69c3 100644 --- a/test/settings/models_page_test.dart +++ b/test/settings/models_page_test.dart @@ -259,12 +259,16 @@ void main() { ), findsOneWidget, ); - expect(find.text('Default'), findsOneWidget); - // Each one its own button: the first is the full reset. - expect(find.text('Use This One…'), findsNWidgets(2)); + expect(find.text('Usable now'), findsOneWidget); + // The other one, queued after it, has no button of its own. + expect( + find.textContaining('Waits its turn: usable once the one above is used'), + findsOneWidget, + ); + expect(find.text('Use This One…'), findsOneWidget); // Cancelled: nothing sent. - await tester.tap(find.text('Use This One…').first); + await tester.tap(find.text('Use This One…')); await tester.pumpAndSettle(); expect(find.text('Use a Claude limit reset?'), findsOneWidget); expect( @@ -277,17 +281,54 @@ void main() { await mouse.removePointer(); await hover(); - // The other one picked: that one used. - await tester.tap(find.text('Use This One…').last); + await tester.tap(find.text('Use This One…')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Reset Now')); + await tester.pumpAndSettle(); + expect(api.claims.single.$1, 'full-reset-2611'); + expect(find.text('Reset: 5h, Weekly'), findsOneWidget); + await tester.tap(find.text('OK')); await tester.pumpAndSettle(); + }); + + testWidgets('the usable reset is listed first; the server refusing it ' + 'as not the next one is told in plain words', (tester) async { + // The later expiring one is the server's next: it comes first. + final api = FakeClaudeResetsApi( + claudeUsageWithResets(next: 'launch-2610-fable'), + )..claimAnswer = {'result': 'ineligible', 'reason': 'not_next_grant'}; + final service = ClaudeResetsService( + api, + now: () => DateTime.utc(2026, 11, 11, 2), + ); + await pump(tester, claudeResets: service); + await tester.pump(); + final mouse = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(mouse.removePointer); + await mouse.addPointer(location: Offset.zero); + await mouse.moveTo(tester.getCenter(find.text('2 resets'))); + await tester.pump(); + await tester.pump(ideHoverDelay + const Duration(milliseconds: 150)); expect( - find.textContaining('New model launch: one Fable reset'), - findsWidgets, + tester.getTopLeft(find.text('New model launch: one Fable reset')).dy, + lessThan( + tester.getTopLeft(find.text('One full limit reset (November 2026)')).dy, + ), ); + expect(find.text('Use This One…'), findsOneWidget); + + await tester.tap(find.text('Use This One…')); + await tester.pumpAndSettle(); await tester.tap(find.text('Reset Now')); await tester.pumpAndSettle(); expect(api.claims.single.$1, 'launch-2610-fable'); - expect(find.text('Reset: 5h, Weekly'), findsOneWidget); + expect( + find.text( + 'Another reset is the one usable now; nothing was used. ' + 'Refresh and try again.', + ), + findsOneWidget, + ); await tester.tap(find.text('OK')); await tester.pumpAndSettle(); });