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(); });