diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 763124f..cd5c315 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -195,10 +195,7 @@ export const jaOverrides = { 'quota.service.xaiPaidHealth': '有料 API の会話が利用可能です。xAI は現在、この OAuth 認証情報の総クォータを提供していません。', 'quota.service.xaiPaidQuotaUnavailable': 'このアカウントの残りクォータは提供されていません。', 'quota.confirm.button': 'リセットを実行', - 'quota.confirm.applicableLabel': '現在適用可能な回数', 'quota.resetting': 'リセットを送信してクォータを更新中…', - 'quota.resetNotApplicable': '現在適用可能なリセット回数がありません。クォータを更新して確認してください', - 'quota.resetCheckFirst': '前回の結果が未確認です。重複リセットを避けるため、先にクォータを更新してください', 'quota.resetResult.submitted': 'リセット要求を送信し、クォータを再取得しました。カードの最新クォータを確認してください。', 'quota.resetResult.refreshFailed': 'リセット要求は送信済みですが、クォータの更新に失敗しました:{error}。再消費を避けるため、先にクォータを更新してください。', 'quota.resetResult.failed': 'リセットの成功を確認できませんでした:{error}。再試行する前にクォータを更新してください。', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 9a942a8..a22291e 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -182,10 +182,7 @@ export const en: Record = { 'quota.service.xaiPaidHealth': 'Paid API chat is available. xAI does not currently provide total quota data for this OAuth credential.', 'quota.service.xaiPaidQuotaUnavailable': 'Remaining quota is not provided for this account.', 'quota.confirm.button': 'Confirm Reset', - 'quota.confirm.applicableLabel': 'Currently applicable resets', 'quota.resetting': 'Submitting reset and refreshing quota…', - 'quota.resetNotApplicable': 'No reset credits currently apply. Refresh quota before trying again', - 'quota.resetCheckFirst': 'The previous result is unconfirmed. Refresh quota first to avoid another reset', 'quota.resetResult.submitted': 'The reset request was submitted and quota was refreshed. Check the latest quota on this card.', 'quota.resetResult.refreshFailed': 'The reset request was submitted, but refreshing quota failed: {error}. Refresh quota first to avoid consuming another reset credit.', 'quota.resetResult.failed': 'Could not confirm the reset: {error}. Refresh quota before deciding whether to retry.', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 363dcd9..7a32628 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -181,10 +181,7 @@ export const zhCN = { 'quota.service.xaiPaidHealth': '付费 API 对话可用。xAI 暂不为此 OAuth 凭证提供额度总量数据。', 'quota.service.xaiPaidQuotaUnavailable': '此账号不提供剩余额度。', 'quota.confirm.button': '确认重置', - 'quota.confirm.applicableLabel': '当前适用次数', 'quota.resetting': '正在提交重置并刷新额度…', - 'quota.resetNotApplicable': '当前没有适用的重置次数,请刷新额度后再确认', - 'quota.resetCheckFirst': '上次操作结果尚未确认,请先刷新额度,避免重复重置', 'quota.resetResult.submitted': '重置请求已提交,额度已重新获取。请查看卡片中的最新额度。', 'quota.resetResult.refreshFailed': '重置请求已提交,但额度刷新失败:{error}。请先刷新查询,避免重复消耗重置机会。', 'quota.resetResult.failed': '无法确认重置成功:{error}。请先刷新额度,再决定是否重试。', diff --git a/src/pages/QuotaPage.tsx b/src/pages/QuotaPage.tsx index 4978fd6..689dfa1 100644 --- a/src/pages/QuotaPage.tsx +++ b/src/pages/QuotaPage.tsx @@ -102,7 +102,6 @@ export function QuotaPage() { confirmText: t('quota.confirm.button'), details: [ { label: t('quota.resetCredits'), value: String(quota.resetCredits ?? '—') }, - { label: t('quota.confirm.applicableLabel'), value: String(quota.resetCreditsApplicable ?? '—') }, { label: t('quota.earliestExpiry'), value: formatQuotaTimestamp(quota.resetCreditsEarliestExpiry, locale) }, ], warning: t('quota.confirm.warning'), @@ -198,7 +197,7 @@ export function QuotaCard({ file, quota, onRefresh, onReset }: { file: AuthFile;
{name}{provider ? providerMeta[provider].label : t('quota.unknownProvider')}{quota.plan ? ' · ' + quota.plan : ''}
- {onReset && (quota.resetCredits ?? 0) > 0 ? : null} + {onReset && (quota.resetCredits ?? 0) > 0 ? : null}
diff --git a/src/services/quotaActions.ts b/src/services/quotaActions.ts index 1d9d9d4..1037ba3 100644 --- a/src/services/quotaActions.ts +++ b/src/services/quotaActions.ts @@ -16,10 +16,8 @@ type QuotaActionOutcome = 'cancelled' | 'success' | 'refresh-error' | 'error'; export const canResetCodexQuota = (file: AuthFile, quota: QuotaState): boolean => providerForFile(file) === 'codex' && !readBoolean(file, 'disabled') - && quota.status === 'success' - && (quota.resetCredits ?? 0) > 0 - && quota.resetCreditsApplicable !== 0 - && !(quota.actionResult?.action === 'reset' && quota.actionResult.status !== 'success'); + && quota.status !== 'loading' + && (quota.resetCredits ?? 0) > 0; async function runConfirmedQuotaAction( file: AuthFile, diff --git a/tests/quotaConfirmation.test.ts b/tests/quotaConfirmation.test.ts index d1e61d2..46013c2 100644 --- a/tests/quotaConfirmation.test.ts +++ b/tests/quotaConfirmation.test.ts @@ -108,35 +108,60 @@ describe('quota action confirmation with fully mocked IPC', () => { expect(upstreamCalls).toHaveLength(0); }); - it('does not offer or execute a reset when no credits currently apply', async () => { - updateQuotaCache({ [key]: { ...previous, resetCreditsApplicable: 0 } }); - let asked = false; - await resetCodexQuotaWithConfirmation(file, async () => { asked = true; return true; }); - expect(asked).toBe(false); + it.each([0, undefined])('allows a confirmed reset with full quota regardless of applicable credits: %s', async (applicable) => { + const quota: QuotaState = { + ...previous, rows: [{ label: '5h', remainingPercent: 100 }], resetCreditsApplicable: applicable, + }; + updateQuotaCache({ [key]: quota }); + expect(canResetCodexQuota(file, quota)).toBe(true); + expect(await resetCodexQuotaWithConfirmation(file, async () => false)).toBe('cancelled'); expect(upstreamCalls).toHaveLength(0); - expect(canResetCodexQuota(file, getQuotaCacheSnapshot()[key])).toBe(false); - expect(canResetCodexQuota({ ...file, disabled: true }, previous)).toBe(false); + expect(getQuotaCacheSnapshot()[key]).toBe(quota); + + let decide!: (confirmed: boolean) => void; + const resetting = resetCodexQuotaWithConfirmation(file, () => new Promise((resolve) => { decide = resolve; })); + expect(upstreamCalls).toHaveLength(0); + expect(getQuotaCacheSnapshot()[key]).toBe(quota); + decide(true); + expect(await resetting).toBe('success'); + expect(upstreamCalls.filter((request) => request.url.endsWith('/consume'))).toHaveLength(1); }); - it('retains quota and reports a failed reset on the account, requiring refresh before retry', async () => { + it('keeps disabled credentials and pending requests unavailable', async () => { + expect(canResetCodexQuota({ ...file, disabled: true }, previous)).toBe(false); + expect(await resetCodexQuotaWithConfirmation({ ...file, disabled: true }, async () => true)).toBe('cancelled'); + updateQuotaCache({ [key]: { ...previous, status: 'loading' } }); + expect(canResetCodexQuota(file, getQuotaCacheSnapshot()[key])).toBe(false); + expect(await resetCodexQuotaWithConfirmation(file, async () => true)).toBe('cancelled'); + expect(upstreamCalls).toHaveLength(0); + }); + + it('reports a failed reset and allows retry after a new confirmation', async () => { consumeError = true; expect(await resetCodexQuotaWithConfirmation(file, async () => true)).toBe('error'); expect(getQuotaCacheSnapshot()[key]).toMatchObject({ rows: previous.rows, actionResult: { action: 'reset', status: 'error', error: 'reset denied' }, }); - expect(canResetCodexQuota(file, getQuotaCacheSnapshot()[key])).toBe(false); - expect(await resetCodexQuotaWithConfirmation(file, async () => true)).toBe('cancelled'); + expect(canResetCodexQuota(file, getQuotaCacheSnapshot()[key])).toBe(true); + expect(await resetCodexQuotaWithConfirmation(file, async () => false)).toBe('cancelled'); expect(upstreamCalls).toHaveLength(1); + consumeError = false; + expect(await resetCodexQuotaWithConfirmation(file, async () => true)).toBe('success'); + expect(upstreamCalls.filter((request) => request.url.endsWith('/consume'))).toHaveLength(2); }); - it('distinguishes an accepted reset from a failed refresh and prevents a second consume', async () => { + it('reports a failed follow-up refresh and requires a new confirmation for another reset', async () => { refreshError = true; expect(await resetCodexQuotaWithConfirmation(file, async () => true)).toBe('refresh-error'); expect(getQuotaCacheSnapshot()[key]).toMatchObject({ rows: previous.rows, actionResult: { action: 'reset', status: 'refresh-error', error: 'usage unavailable' }, }); - await resetCodexQuotaWithConfirmation(file, async () => true); + expect(canResetCodexQuota(file, getQuotaCacheSnapshot()[key])).toBe(true); + expect(await resetCodexQuotaWithConfirmation(file, async () => false)).toBe('cancelled'); expect(upstreamCalls.filter((request) => request.url.endsWith('/consume'))).toHaveLength(1); + refreshError = false; + expect(await resetCodexQuotaWithConfirmation(file, async () => true)).toBe('success'); + expect(upstreamCalls.filter((request) => request.url.endsWith('/consume'))).toHaveLength(2); }); it('keeps application confirmations out of native and browser dialog APIs', async () => { diff --git a/tests/quotaRendering.test.tsx b/tests/quotaRendering.test.tsx index 4608348..5d95192 100644 --- a/tests/quotaRendering.test.tsx +++ b/tests/quotaRendering.test.tsx @@ -10,17 +10,29 @@ const render = (quota: QuotaState, provider = 'codex') => renderToStaticMarkup( ); describe('quota card rendering', () => { - it('当前适用次数为零时禁用重置按钮,并保留额度详情', () => { + it('当前适用次数为零时仍可重置,并保留额度详情', () => { const html = render({ status: 'success', rows: [], resetCredits: 2, resetCreditsApplicable: 0, resetCreditsError: 'temporary failure', subscriptionActiveUntil: '2030-01-01T00:00:00Z', }); - expect(html).toMatch(/]*disabled=""[^>]*>重置额度<\/button>/); + expect(html).toMatch(/]*title="重置额度"[^>]*>重置额度<\/button>/); + expect(html).not.toMatch(/]*disabled=""[^>]*>重置额度<\/button>/); + expect(html).not.toContain('当前没有适用的重置次数'); expect(html).toContain('当前适用:0 次'); expect(html).toContain('temporary failure'); expect(html).toContain('订阅到期'); }); + it.each(['error', 'refresh-error'] as const)('重置结果为 %s 时仍可再次点击重置', (status) => { + const html = render({ + status: 'success', rows: [], resetCredits: 2, + actionResult: { action: 'reset', status, error: 'temporary failure' }, + }); + expect(html).toMatch(/]*title="重置额度"[^>]*>重置额度<\/button>/); + expect(html).not.toMatch(/]*disabled=""[^>]*>重置额度<\/button>/); + expect(html).toContain('temporary failure'); + }); + it('xAI 付费账号保留额度说明和刷新入口,不显示可用性测试', () => { const html = render({ status: 'success', rows: [{ label: '付费 API 账号', remainingPercent: null, detail: '上游未提供剩余额度' }] }, 'xai'); expect(html).toContain('上游未提供剩余额度');