fix(quota): remove extra reset restrictions while preserving confirmation
- Allow resets when applicable credits are zero or a previous reset failed - Remove applicable-credit details from the confirmation and obsolete tooltips - Keep the existing in-app dialog and require explicit confirmation before submitting - Update regression tests for cancellation, confirmation, and retries
This commit is contained in:
1 parent
5548324c8f
commit
805e2aece0
7 files changed
+54
-29
No files matched your search
@@ -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}。再試行する前にクォータを更新してください。',
|
||||
|
||||
@@ -182,10 +182,7 @@ export const en: Record<MessageKey, string> = {
|
||||
'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.',
|
||||
|
||||
@@ -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}。请先刷新额度,再决定是否重试。',
|
||||
|
||||
@@ -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;
|
||||
<div className="real-quota-card-header">
|
||||
<div><strong title={name}>{name}</strong><span>{provider ? providerMeta[provider].label : t('quota.unknownProvider')}{quota.plan ? ' · ' + quota.plan : ''}</span></div>
|
||||
<div className="quota-card-actions">
|
||||
{onReset && (quota.resetCredits ?? 0) > 0 ? <button type="button" className="secondary-button compact-button" onClick={onReset} disabled={!canResetCodexQuota(file, quota)} title={quota.resetCreditsApplicable === 0 ? t('quota.resetNotApplicable') : quota.actionResult?.action === 'reset' && quota.actionResult.status !== 'success' ? t('quota.resetCheckFirst') : t('quota.reset')}>{t('quota.reset')}</button> : null}
|
||||
{onReset && (quota.resetCredits ?? 0) > 0 ? <button type="button" className="secondary-button compact-button" onClick={onReset} disabled={!canResetCodexQuota(file, quota)} title={t('quota.reset')}>{t('quota.reset')}</button> : null}
|
||||
<button type="button" className="icon-button quiet" onClick={onRefresh} disabled={disabled || quota.status === 'loading'} title={disabled ? t('quota.fileDisabled') : t('quota.refresh')}><RefreshCw size={16} className={quota.status === 'loading' ? 'spin' : ''} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(/<button[^>]*disabled=""[^>]*>重置额度<\/button>/);
|
||||
expect(html).toMatch(/<button[^>]*title="重置额度"[^>]*>重置额度<\/button>/);
|
||||
expect(html).not.toMatch(/<button[^>]*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(/<button[^>]*title="重置额度"[^>]*>重置额度<\/button>/);
|
||||
expect(html).not.toMatch(/<button[^>]*disabled=""[^>]*>重置额度<\/button>/);
|
||||
expect(html).toContain('temporary failure');
|
||||
});
|
||||
|
||||
it('xAI 付费账号保留额度说明和刷新入口,不显示可用性测试', () => {
|
||||
const html = render({ status: 'success', rows: [{ label: '付费 API 账号', remainingPercent: null, detail: '上游未提供剩余额度' }] }, 'xai');
|
||||
expect(html).toContain('上游未提供剩余额度');
|
||||
|
||||
Reference in new issue
Block a user